mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 12:49:34 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 |
+11
-10
@@ -12,22 +12,23 @@ and this project adheres to
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
- ✨(frontend) add connection test feature
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||
- 📝(legal) update terms of service
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||
- 🐛(backend) preserve recording metadata when updating room access
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
publiccodeYmlVersion: 0.5.0
|
||||
name: LaSuite Meet
|
||||
applicationSuite: LaSuite
|
||||
url: https://github.com/suitenumerique/meet
|
||||
releaseDate: 2026-07-22
|
||||
platforms:
|
||||
- web
|
||||
organisation:
|
||||
name: DINUM
|
||||
uri: https://numerique.gouv.fr
|
||||
fundedBy:
|
||||
- name: Direction interministérielle du numérique (DINUM)
|
||||
uri: https://www.numerique.gouv.fr
|
||||
developmentStatus: stable
|
||||
softwareType: standalone/web
|
||||
intendedAudience:
|
||||
countries:
|
||||
- FR
|
||||
description:
|
||||
en:
|
||||
localisedName: LaSuite Meet
|
||||
shortDescription: "Open Source video conference solution, based on LiveKit"
|
||||
longDescription: "Open Source video conference application, based on LiveKit,
|
||||
Django and React. It is the official web video conference application of
|
||||
French Ministries."
|
||||
features:
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
legal:
|
||||
license: MIT
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Samuel Paccoud"
|
||||
email: samuel.paccoud@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
- name: "Antoine Lebaud"
|
||||
email: antoine.lebaud.ext@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
localisation:
|
||||
localisationReady: true
|
||||
availableLanguages:
|
||||
- fr
|
||||
- de
|
||||
- en
|
||||
- nl
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "^26.3.2",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
|
||||
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
|
||||
"version": "26.3.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.2",
|
||||
"i18next": "26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""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.utils import generate_token
|
||||
|
||||
CONNECTION_TEST_USERNAME = "Test connexion"
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
|
||||
def get_connection_test_config(request):
|
||||
"""Return 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
|
||||
|
||||
return Response(
|
||||
{
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": room,
|
||||
"token": generate_token(
|
||||
room=room,
|
||||
user=request.user,
|
||||
username=CONNECTION_TEST_USERNAME,
|
||||
is_admin_or_owner=False,
|
||||
ttl=timedelta(seconds=expires_in),
|
||||
),
|
||||
"expires_in": expires_in,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -73,15 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation 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"
|
||||
|
||||
@@ -9,6 +9,11 @@ from livekit import api
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -39,10 +44,15 @@ class RecordingEventsService:
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
import logging
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
from .exceptions import (
|
||||
RecordingStartError,
|
||||
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core import models
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -177,10 +182,15 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
utils.update_room_metadata(
|
||||
room_name, {}, ["recording_mode", "recording_status"]
|
||||
RoomManagement().update_metadata(
|
||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
@@ -218,21 +228,9 @@ class LiveKitEventsService:
|
||||
|
||||
# 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 room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
if self._is_connection_test_room(data.room.name):
|
||||
logger.info(
|
||||
"Ignoring room_started event for connection test room '%s'.",
|
||||
data.room.name,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
@@ -258,13 +256,6 @@ class LiveKitEventsService:
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
|
||||
if self._is_connection_test_room(data.room.name):
|
||||
logger.info(
|
||||
"Ignoring room_finished event for connection test room '%s'.",
|
||||
data.room.name,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
ListRoomsRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
)
|
||||
@@ -29,20 +30,45 @@ class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||
"""Update a LiveKit room's metadata.
|
||||
async def update_metadata(
|
||||
self,
|
||||
room_name: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
remove_keys: Optional[list[str]] = None,
|
||||
):
|
||||
"""Merge values into a LiveKit room's metadata.
|
||||
|
||||
The `room_name` corresponds to the LiveKit room identifier
|
||||
(i.e. the Room model's UUID as a string).
|
||||
|
||||
Raises:
|
||||
RoomNotFoundException: the room does not exist in LiveKit.
|
||||
RoomManagementException: the metadata update otherwise fails.
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
|
||||
|
||||
if not response.rooms:
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist")
|
||||
|
||||
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
|
||||
|
||||
for key in remove_keys or []:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **(metadata or {})}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name,
|
||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||
metadata=json.dumps(updated_metadata),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_start_recording_success(
|
||||
mock_update_room_metadata, mediator, mock_worker_service
|
||||
):
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||
"""Test successful recording start"""
|
||||
# Setup
|
||||
worker_id = "test-worker-123"
|
||||
@@ -60,7 +58,7 @@ def test_start_recording_success(
|
||||
assert mock_recording.worker_id == worker_id
|
||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(mock_recording.room.id),
|
||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||
)
|
||||
@@ -69,9 +67,9 @@ def test_start_recording_success(
|
||||
@pytest.mark.parametrize(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_worker_errors(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||
mock_update_metadata, mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||
assert mock_recording.worker_id is None
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||
mock_update_metadata, mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
|
||||
@@ -6,8 +6,6 @@ Test LiveKitEvents service.
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
@@ -22,8 +20,9 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import MetadataUpdateException, NotificationError
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -72,9 +71,9 @@ def test_initialization(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
@@ -88,8 +87,8 @@ def test_handle_egress_ended_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -106,9 +105,9 @@ def test_handle_egress_ended_success(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_success(
|
||||
mock_update_room_metadata, egress_status, status, service
|
||||
mock_update_metadata, egress_status, status, service
|
||||
):
|
||||
"""Should successfully update room's metadata."""
|
||||
|
||||
@@ -119,7 +118,7 @@ def test_handle_egress_updated_success(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": status}
|
||||
)
|
||||
|
||||
@@ -131,9 +130,9 @@ def test_handle_egress_updated_success(
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_room_metadata, egress_status, service
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
@@ -144,7 +143,7 @@ def test_handle_egress_updated_non_handled(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -155,9 +154,9 @@ def test_handle_egress_updated_non_handled(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop and save recording when metadata's update fails."""
|
||||
|
||||
@@ -166,7 +165,7 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -180,9 +179,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
@@ -202,15 +201,15 @@ def test_handle_egress_ended_notification_fails(
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_found(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
@@ -225,16 +224,16 @@ def test_handle_egress_ended_recording_not_found(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
@@ -246,8 +245,8 @@ def test_handle_egress_ended_recording_not_active(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -255,9 +254,9 @@ def test_handle_egress_ended_recording_not_active(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
@@ -269,16 +268,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||
mock_update_room_metadata, mock_collector_class, service, settings
|
||||
mock_update_metadata, mock_collector_class, service, settings
|
||||
):
|
||||
"""Should call MetadataCollectorService.stop when it exists."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
@@ -308,7 +307,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
@@ -337,7 +336,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
@@ -347,7 +346,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
mock_update_room_metadata,
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
@@ -380,7 +379,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status, expected_status",
|
||||
[
|
||||
@@ -389,7 +388,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
],
|
||||
)
|
||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||
mock_update_room_metadata,
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
@@ -426,9 +425,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_room_metadata, egress_status, service, settings
|
||||
mock_update_metadata, egress_status, service, settings
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
@@ -447,9 +446,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_room_metadata, status, service, settings
|
||||
mock_update_metadata, status, service, settings
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
|
||||
@@ -552,23 +551,6 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_clear_cache.assert_not_called()
|
||||
|
||||
|
||||
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_ignores_connection_test_room(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should ignore room_finished events for connection test rooms."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_not_called()
|
||||
mock_clear_cache.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -619,15 +601,6 @@ def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||
def test_handle_room_started_ignores_connection_test_room(service, settings):
|
||||
"""Should ignore room_started events for connection test rooms."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
||||
mock_data = mock.MagicMock()
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Test connection test API endpoint."""
|
||||
|
||||
import uuid
|
||||
|
||||
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.get("/api/v1.0/connection-test/")
|
||||
response_b = client.get("/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.get("/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
|
||||
@@ -8,7 +8,6 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
||||
|
||||
from core.addons import viewsets as addons_viewsets
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.api.connection_test import get_connection_test_config
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
@@ -47,11 +46,6 @@ urlpatterns = [
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
path(
|
||||
"connection-test/",
|
||||
get_connection_test_config,
|
||||
name="connection_test",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,7 +12,6 @@ import mimetypes
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
@@ -32,7 +31,6 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
@@ -69,7 +67,6 @@ def generate_token(
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
participant_id: Optional[str] = None,
|
||||
ttl: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -85,7 +82,6 @@ def generate_token(
|
||||
is_admin_or_owner (bool): Whether user has admin privileges
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
@@ -134,8 +130,6 @@ def generate_token(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
)
|
||||
if ttl is not None:
|
||||
token = token.with_ttl(ttl)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
@@ -263,57 +257,6 @@ async def notify_participants(room_name: str, notification_data: dict):
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
class MetadataUpdateException(Exception):
|
||||
"""Room's metadata update fails."""
|
||||
|
||||
|
||||
@async_to_sync
|
||||
async def update_room_metadata(
|
||||
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
||||
):
|
||||
"""Update LiveKit room metadata by merging new values with existing metadata.
|
||||
|
||||
Args:
|
||||
room_name: Name of the room to update
|
||||
metadata: Dictionary of metadata key-values to add/update
|
||||
remove_keys: Optional list of keys to remove from existing metadata.
|
||||
"""
|
||||
|
||||
lkapi = create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[room_name],
|
||||
)
|
||||
)
|
||||
|
||||
if not response.rooms:
|
||||
return
|
||||
|
||||
room = response.rooms[0]
|
||||
|
||||
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
||||
|
||||
if remove_keys:
|
||||
for key in remove_keys:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **metadata}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
raise MetadataUpdateException(
|
||||
f"Failed to update metadata for room {room_name}: {e}"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
|
||||
@@ -349,11 +349,6 @@ class Base(Configuration):
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"connection_test": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -660,16 +655,6 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
default=False,
|
||||
)
|
||||
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
|
||||
600,
|
||||
environ_name="CONNECTION_TEST_TOKEN_TTL_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(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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/')
|
||||
@@ -1,245 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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
|
||||
@@ -1,47 +0,0 @@
|
||||
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' }))
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -13,8 +13,7 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRoomData } from './useRoomData'
|
||||
|
||||
/**
|
||||
* Shape of the LiveKit room metadata blob pushed by the backend.
|
||||
* Matches RoomManagement.update_metadata → {"configuration": room.configuration}
|
||||
* The subset of LiveKit's room metadata this hook actually uses.
|
||||
*/
|
||||
type RoomLiveKitMetadata = {
|
||||
configuration?: RoomConfiguration
|
||||
|
||||
@@ -266,16 +266,6 @@ 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
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
"paragraphs": [
|
||||
"Visio ist das Produkt, dessen Nutzung in diesen Bedingungen beschrieben wird und das auf der folgenden Seite vorgestellt ist: https://visio.numerique.gouv.fr.",
|
||||
"Nutzerinnen und Nutzer von Visio sind alle Personen, die Visio zu Zwecken der Videokommunikation verwenden, unabhängig davon, ob sie Mitarbeitende, Partner oder Dienstleister der zuständigen Verwaltungen sind. Die zuständige Verwaltung ist die staatliche Verwaltung oder ihr unterstellte Einrichtung, die Visio ihren Mitarbeitenden, Partnern oder Dienstleistern zur Verfügung stellt.",
|
||||
"Raumorganisatorinnen und -organisatoren sind Nutzerinnen und Nutzer, die nach Anmeldung über ProConnect für einen Videokonferenzraum verantwortlich sind."
|
||||
"Raumorganisatorinnen und -organisatoren sind Nutzerinnen und Nutzer, die nach Anmeldung über ProConnect für einen Videokonferenzraum verantwortlich sind.",
|
||||
"Die zuständige Verwaltung ist die Verwaltung, die dem staatlichen Informations- und Kommunikationssystem unterliegt und Visio ihren Mitarbeitenden, Partnern oder Dienstleistern zur Verfügung stellt."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Zugang zur Anwendung",
|
||||
"title": "4. Zugang zum Dienst",
|
||||
"definition": "Visio wird den Nutzerinnen und Nutzern von ihrer zuständigen Verwaltung zur Verfügung gestellt. Organisatorinnen und Organisatoren können anschließend unter eigener Verantwortung einen Zugangslink zum Raum an beliebige Personen weitergeben."
|
||||
},
|
||||
"article5": {
|
||||
@@ -32,7 +33,7 @@
|
||||
"section1": {
|
||||
"title": "5.1. Funktionen für Organisatorinnen und Organisatoren",
|
||||
"content": "Visio ermöglicht Mitarbeitenden staatlicher Verwaltungen und ihnen unterstellter Einrichtungen, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.",
|
||||
"paragraph1": "Jede Mitarbeiterin und jeder Mitarbeiter des Staates oder einer ihm unterstellten Einrichtung, deren Tätigkeiten mit der Nutzung von Visio vereinbar sind oder die keinen besonderen Vertraulichkeits- oder Berufsgeheimnispflichten unterliegen, kann eine Videokonferenz erstellen und organisieren. Der Einladungslink kann anschließend mit anderen Personen, Nutzerinnen und Nutzern, innerhalb oder außerhalb der Verwaltung geteilt werden.",
|
||||
"paragraph1": "Sie oder er kann anschließend den Besprechungslink mit anderen Personen, Nutzerinnen und Nutzern, teilen, unabhängig davon, ob diese zur Verwaltung gehören oder nicht.",
|
||||
"paragraph2": "Zu diesem Zweck kann jede Raumorganisatorin bzw. jeder Raumorganisator:",
|
||||
"capabilities": [
|
||||
"den Zugang zum Raum durch einen Warteraum schützen;",
|
||||
@@ -41,7 +42,7 @@
|
||||
"eine Nutzerin oder einen Nutzer aus dem Raum entfernen;",
|
||||
"das Transkriptionstool der Sitzung nach Einholung der Zustimmung der Nutzerinnen und Nutzer unter eigener Verantwortung verwenden."
|
||||
],
|
||||
"paragraph3": "Nur Mitarbeitende, deren ProConnect-Konto zu einer zur Raumorganisation in Visio berechtigten Domäne gehört oder die keiner besonderen Vertraulichkeits- oder Berufsgeheimnispflicht unterliegen, dürfen die Anwendung und ihre Funktionen nutzen oder kontrollieren.",
|
||||
"paragraph3": "Mitarbeitende sollten sich an ihre zuständige Verwaltung wenden, um den Rahmen der Nutzung von Visio sicherzustellen, insbesondere bei Besprechungen mit erhöhten Vertraulichkeitsanforderungen.",
|
||||
"paragraph4": "Das automatische Transkriptionstool kann von Organisatorinnen und Organisatoren über das Einstellungsmenü des Raums genutzt werden. Es liegt in ihrer Verantwortung, die Zustimmung der an der transkribierten Sitzung teilnehmenden Nutzerinnen und Nutzer sicherzustellen. Die Transkriptionssprache kann im selben Menü geändert werden. Am Ende der Sitzung erhält die Organisatorin bzw. der Organisator einen Link zum Zugriff auf die im von der DINUM betriebenen Tool „Docs“ gespeicherte Transkription."
|
||||
},
|
||||
"section2": {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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,7 +36,6 @@
|
||||
"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,7 +13,6 @@
|
||||
"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"
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
"paragraphs": [
|
||||
"Visio is the product whose use is described in these terms and presented on the following page: https://visio.numerique.gouv.fr.",
|
||||
"Users of Visio are any persons using Visio for videoconferencing communication purposes, whether they are staff members, partners, or service providers of the relevant administrations. The supervising administration is the State administration or body under its supervision that makes Visio available to its staff members, partners, or service providers.",
|
||||
"Room organizers are users who are responsible for a videoconference room after logging in via ProConnect."
|
||||
"Room organizers are users who are responsible for a videoconference room after logging in via ProConnect.",
|
||||
"The supervising administration is the administration that falls under the State information and communication system and makes Visio available to its staff members, partners, or service providers."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Access to the application",
|
||||
"title": "4. Access to the service",
|
||||
"definition": "Visio is made available to users by their supervising administration. Organizers may then share a link granting access to the room with any person, under their responsibility."
|
||||
},
|
||||
"article5": {
|
||||
@@ -32,7 +33,7 @@
|
||||
"section1": {
|
||||
"title": "5.1. Features available to organizers",
|
||||
"content": "Visio allows staff of State administrations and bodies under their supervision to create a videoconference room from a standard workstation, linked to their ProConnect account.",
|
||||
"paragraph1": "Any State staff member or staff member of a body under State supervision, whose activities are compatible with the use of Visio or who is not subject to specific confidentiality or professional secrecy obligations, may create and organize a videoconference. They may then share the meeting link with other persons, users, whether or not they belong to the administration.",
|
||||
"paragraph1": "They may then share the meeting link with other persons, users, whether or not they belong to the administration.",
|
||||
"paragraph2": "For this purpose, each room organizer may:",
|
||||
"capabilities": [
|
||||
"Protect access to the room with a waiting room;",
|
||||
@@ -41,7 +42,7 @@
|
||||
"Remove a user from the room;",
|
||||
"Use the meeting transcription tool after obtaining the consent of users, under their responsibility."
|
||||
],
|
||||
"paragraph3": "Only staff members whose ProConnect account belongs to a domain authorized to organize rooms on Visio or who are not subject to specific confidentiality or professional secrecy obligations may use or have control over the application and its features.",
|
||||
"paragraph3": "Staff members must contact their supervising administration to ensure the proper framework for using Visio, particularly for meetings subject to heightened confidentiality requirements.",
|
||||
"paragraph4": "The automatic transcription tool can be used by organizers from the room settings menu. Organizers are responsible for ensuring the consent of users participating in the transcribed meeting. The transcription language can be changed from the same menu. At the end of the meeting, a link is sent to the organizer to access the transcription stored in the tool called Docs operated by DINUM."
|
||||
},
|
||||
"section2": {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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,7 +36,6 @@
|
||||
"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,7 +13,6 @@
|
||||
"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"
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
"paragraphs": [
|
||||
"Visio est le produit dont l’utilisation est décrite dans les présentes modalités et présenté sur la page suivante : https://visio.numerique.gouv.fr.",
|
||||
"Les utilisateurs et utilisatrices de Visio sont toute personne utilisant Visio à des fins de communication en visioconférence, qu’elles soient des agents, des partenaires ou des prestataires des administrations de rattachement. L’administration de rattachement est l’administration de l’Etat et des organismes sous sa tutelle qui met à disposition Visio à ses agents, partenaires ou prestataires.",
|
||||
"Les organisateurs et organisatrices de salon sont des utilisateurs et utilisatrices qui sont responsables d’un salon de visioconférence après s’être connecté par ProConnect."
|
||||
"Les organisateurs et organisatrices de salon sont des utilisateurs et utilisatrices qui sont responsables d’un salon de visioconférence après s’être connecté par ProConnect.",
|
||||
"L’administration de rattachement est l’administration relevant du système d’information et de communication de l’Etat qui met à disposition Visio à ses agents, partenaires ou prestataires."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Accès à l’application",
|
||||
"title": "4. Accès au service",
|
||||
"definition": "Visio est mis à disposition des utilisateurs et utilisatrices par leur administration de rattachement. Les organisateurs et organisatrices peuvent ensuite transmettre un lien permettant d’accéder au salon à toute personne, sous leur responsabilité."
|
||||
},
|
||||
"article5": {
|
||||
@@ -32,7 +33,7 @@
|
||||
"section1": {
|
||||
"title": "5.1. Fonctionnalités ouvertes aux organisateurs et organisatrices",
|
||||
"content": "Visio offre aux agents des administrations de l’Etat et des organismes sous sa tutelle la possibilité de créer un salon de visioconférence depuis un poste de travail standard, lié à leur compte ProConnect.",
|
||||
"paragraph1": "Tout agent de l’État ou d’un organisme sous sa tutelle, dont les activités sont compatibles avec l’utilisation de Visio ou qui n’est pas soumis à des obligations particulières de confidentialité ou de secret professionnel, peut créer et organiser une visioconférence. Il ou elle peut ensuite partager le lien de la réunion avec d’autres personnes, utilisateurs et utilisatrices, qu’elles fassent partie de l’administration ou non.",
|
||||
"paragraph1": "Il ou elle peut ensuite partager le lien de la réunion avec d’autres personnes, utilisateurs et utilisatrices, qu’elles fassent partie de l’administration ou non.",
|
||||
"paragraph2": "A cet effet, chaque organisateur ou organisatrice de salon peut :",
|
||||
"capabilities": [
|
||||
"Protéger l’accès au salon par une salle d’attente ;",
|
||||
@@ -41,7 +42,7 @@
|
||||
"Exclure un utilisateur ou utilisatrice du salon ;",
|
||||
"Utiliser l’outil de transcription de la réunion après avoir obtenu l’aval des utilisateurs et des utilisatrices, sous sa responsabilité."
|
||||
],
|
||||
"paragraph3": "Seuls les agents, dont le compte ProConnect relève d’un domaine autorisé à organiser des salons sur Visio ou qui <u>ne sont pas soumis à une obligation de confidentialité particulière ou de secret professionnel</u>, peuvent utiliser ou avoir la maîtrise de l’application et de ses fonctionnalités.",
|
||||
"paragraph3": "Les agents doivent se rapprocher de leur administration de rattachement pour s'assurer du cadre d'utilisation en sein de Visio, notamment en ce qui concerne des réunions soumises à des obligations renforcées de confidentialité.",
|
||||
"paragraph4": "L’outil de transcription automatique peut être utilisé par les organisateurs et organisatrices depuis le menu des paramètres du salon. Il appartient aux organisateurs et organisatrices de s’assurer de l’aval des utilisateurs et utilisatrices participants à la réunion transcrite. La langue de transcription peut être modifiée depuis le même menu. À la fin de la réunion, un lien est transmis à l’organisateur ou l’organisatrice pour accéder à la transcription conservée sur l’outil intitulé Docs opéré par la DINUM."
|
||||
},
|
||||
"section2": {
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
"paragraphs": [
|
||||
"Visio is het product waarvan het gebruik in deze voorwaarden wordt beschreven en dat wordt gepresenteerd op de volgende pagina: https://visio.numerique.gouv.fr.",
|
||||
"Gebruikers van Visio zijn alle personen die Visio gebruiken voor videocommunicatiedoeleinden, ongeacht of zij medewerkers, partners of dienstverleners zijn van de betrokken administraties. De bevoegde administratie is de overheidsadministratie of de onder haar toezicht staande instantie die Visio ter beschikking stelt aan haar medewerkers, partners of dienstverleners.",
|
||||
"Ruimteorganisatoren zijn gebruikers die verantwoordelijk zijn voor een videoconferentieruimte na inloggen via ProConnect."
|
||||
"Ruimteorganisatoren zijn gebruikers die verantwoordelijk zijn voor een videoconferentieruimte na inloggen via ProConnect.",
|
||||
"De bevoegde administratie is de administratie die onder het informatie- en communicatiesysteem van de Staat valt en Visio ter beschikking stelt aan haar medewerkers, partners of dienstverleners."
|
||||
]
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Toegang tot de applicatie",
|
||||
"title": "4. Toegang tot de dienst",
|
||||
"definition": "Visio wordt aan gebruikers ter beschikking gesteld door hun bevoegde administratie. Organisatoren kunnen vervolgens, onder hun verantwoordelijkheid, een toegangslink tot de ruimte delen met elke persoon."
|
||||
},
|
||||
"article5": {
|
||||
@@ -32,7 +33,7 @@
|
||||
"section1": {
|
||||
"title": "5.1. Functionaliteiten voor organisatoren",
|
||||
"content": "Visio biedt medewerkers van overheidsadministraties en onder hun toezicht staande instanties de mogelijkheid om vanaf een standaardwerkstation een videoconferentieruimte aan te maken, gekoppeld aan hun ProConnect-account.",
|
||||
"paragraph1": "Elke medewerker van de Staat of van een onder zijn toezicht staande instantie, van wie de activiteiten verenigbaar zijn met het gebruik van Visio of die niet onder specifieke vertrouwelijkheids- of beroepsgeheimverplichtingen valt, kan een videoconferentie aanmaken en organiseren. De vergaderlink kan vervolgens worden gedeeld met andere personen, gebruikers, binnen of buiten de administratie.",
|
||||
"paragraph1": "Hij of zij kan vervolgens de vergaderlink delen met andere personen, gebruikers, of zij nu wel of niet deel uitmaken van de administratie.",
|
||||
"paragraph2": "Daartoe kan elke ruimteorganisator:",
|
||||
"capabilities": [
|
||||
"de toegang tot de ruimte beveiligen met een wachtruimte;",
|
||||
@@ -41,7 +42,7 @@
|
||||
"een gebruiker uit de ruimte verwijderen;",
|
||||
"de transcriptietool van de vergadering gebruiken na toestemming van de gebruikers, onder eigen verantwoordelijkheid."
|
||||
],
|
||||
"paragraph3": "Alleen medewerkers van wie het ProConnect-account behoort tot een domein dat gemachtigd is om ruimtes op Visio te organiseren, of die niet onder specifieke vertrouwelijkheids- of beroepsgeheimverplichtingen vallen, mogen de applicatie en haar functionaliteiten gebruiken of beheren.",
|
||||
"paragraph3": "Medewerkers moeten contact opnemen met hun bevoegde administratie om zeker te zijn van het gebruikskader binnen Visio, met name voor vergaderingen waarvoor strengere vertrouwelijkheidsverplichtingen gelden.",
|
||||
"paragraph4": "De automatische transcriptietool kan door organisatoren worden gebruikt via het instellingenmenu van de ruimte. Het is de verantwoordelijkheid van de organisatoren om de toestemming te verkrijgen van de gebruikers die deelnemen aan de getranscribeerde vergadering. De transcriptietaal kan in hetzelfde menu worden gewijzigd. Aan het einde van de vergadering ontvangt de organisator een link om de transcriptie te raadplegen die wordt bewaard in de door DINUM beheerde tool “Docs”."
|
||||
},
|
||||
"section2": {
|
||||
|
||||
@@ -20,9 +20,6 @@ const AccessibilityRoute = lazy(
|
||||
)
|
||||
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
||||
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
||||
const ConnectionTestRoute = lazy(
|
||||
() => import('@/features/connection-test/routes/ConnectionTest')
|
||||
)
|
||||
|
||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||
|
||||
@@ -30,7 +27,6 @@ export const routes: Record<
|
||||
| 'home'
|
||||
| 'room'
|
||||
| 'feedback'
|
||||
| 'connectionTest'
|
||||
| 'legalTerms'
|
||||
| 'accessibility'
|
||||
| 'termsOfService'
|
||||
@@ -61,11 +57,6 @@ export const routes: Record<
|
||||
path: '/feedback',
|
||||
Component: FeedbackRoute,
|
||||
},
|
||||
connectionTest: {
|
||||
name: 'connectionTest',
|
||||
path: '/test-connection',
|
||||
Component: ConnectionTestRoute,
|
||||
},
|
||||
legalTerms: {
|
||||
name: 'legalTerms',
|
||||
path: '/mentions-legales',
|
||||
|
||||
@@ -31,19 +31,6 @@ 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