Pilotage actif en direct — module exercizer (D3)
Fonctionnement technique de l'écran de pilotage actif en direct d'un sujet planifié
(SubjectScheduled) : les 5 nouvelles routes REST, le canal WebSocket applicatif qui pousse les
événements en temps réel, le repli HTTP si ce canal échoue, et le modèle de données associé.
Ce que voit l'utilisateur (scénario enseignant / élève) : Exercices — Piloter une séance en direct ; actions autorisées par profil : Exercices — Capacités par profil.
Vue d'ensemble
Un enseignant qui a planifié un sujet peut, pendant la fenêtre horaire prévue (entre begin_date et
due_date), suivre l'avancement de chaque élève et agir sur la séance : pause/reprise, prolongation
de temps, remise forcée, message ciblé. Deux canaux coexistent :
- les actions de pilotage passent par les routes REST habituelles du module (port
8105) ; - la diffusion en direct des changements passe par un WebSocket applicatif dédié (port
8106), avec un repli par sondage HTTP si ce canal n'est pas disponible.
Le WebSocket est push-only : le client n'envoie jamais d'action dessus, seulement les routes REST en émettent. Le canal ne sert qu'à prévenir les navigateurs connectés qu'un événement a eu lieu ; les calculs (statut consolidé, temps restant) restent calculés côté serveur.
Routes REST
Toutes scopées par :id = subjectScheduledId, protégées par le filtre SubjectScheduledOwner
(seul l'enseignant propriétaire du sujet planifié peut piloter sa séance) :
| Route | Usage |
|---|---|
GET /subject-scheduled/:id/pilotage | état consolidé de la séance : statut de chaque élève, temps restant calculé côté serveur (buildPilotageStateResponse) |
PUT /subject-scheduled/:id/pilotage/pause | met la séance en pause (tous les élèves) |
PUT /subject-scheduled/:id/pilotage/resume | reprend une séance en pause |
PUT /subject-scheduled/:id/pilotage/extend-time | prolonge le temps ; corps { minutes, studentId? } — studentId absent = toute la classe |
PUT /subject-scheduled/:id/pilotage/force-submit | force la remise de la copie d'un élève ; corps { studentId } |
POST /subject-scheduled/:id/pilotage/message | diffuse un message, non persisté ; corps { message, studentId? } |
Les actions relance (POST /subject-copy/custom/reminder) et exclusion
(POST /subject-copy/action/exclude) existaient déjà : aucune route backend ajoutée pour elles,
seulement exposées depuis le nouvel écran de pilotage.
Chaque route qui modifie un état (pause, resume, extend-time, force-submit, message) répond
en REST et appelle broadcastPilotage(subjectScheduledId, event), qui relaie l'événement sur le
WebSocket si un PilotageWebSocketController est configuré (voir plus bas — il peut être null si le
bloc real-time est absent de la configuration, auquel cas seul le repli HTTP fonctionne).
Règles métier appliquées côté backend
extend-timeignore silencieusement les copies déjà rendues (WHERE submitted_date IS NULL) plutôt que de faire échouer toute l'action de classe pour un seul élève déjà rendu.force-submitrépond en erreur (exercizer.pilotage.force.submit.refused) si la copie est déjà rendue ou si l'élève n'a pas de copie sur cette séance — c'est le backend qui refuse, pas seulement le frontend qui masque le bouton.messagen'est pas persisté : diffusé uniquement aux sockets connectés au moment de l'envoi, hors périmètre CCTP (ce n'est pas un canal de messagerie).
Mécanisme WebSocket
Pattern repris de collaborative-wall
Aucun bridge générique Vert.x EventBus↔navigateur n'existe dans le socle commun entcore. Le
pilotage réplique donc le pattern déjà utilisé en production par collaborative-wall
(WallWebSocketController) plutôt que le pattern chat-nats (fan-out multi-instances via NATS) :
- authentification par cookie de session signé (
CookieHelper/UserAuthFilter.SESSION_ID, résolu enUserInfosviaUserUtils.getSession) ; - une map en mémoire
subjectScheduledId → wsId → ServerWebSocket(PilotageWebSocketController,modules/exercizer/src/main/java/fr/openent/exercizer/controllers/PilotageWebSocketController.java) ; - diffusion texte (JSON encodé) à tous les sockets connectés sur un même
subjectScheduledId— enseignant et élèves reçoivent le même flux, chacun filtrant côté client ce qui le concerne (scope/studentIddans le payload) ; - le contrôle d'accès à la connexion réutilise
subjectScheduledService.canAccessPilotage(403 si l'utilisateur n'a pas le droit de rejoindre ce canal).
// PilotageWebSocketController.java
private final Map<String, Map<String, ServerWebSocket>> subjectScheduledIdToWSIdToWS = new HashMap<>();
Contrairement à chat-nats, cette implémentation reste mono-instance (map en mémoire, pas de
fan-out NATS). Si exercizer tourne un jour en plusieurs répliques sans affinité de session, un
élève connecté à une autre instance que l'enseignant ne recevra pas les diffusions. C'est une limite
documentée dès l'origine (cf. spec D3), pas corrigée dans cette itération — la même limite s'applique
déjà à collaborative-wall avec le même pattern. Point à vérifier auprès de l'équipe ops avant de
généraliser ce pattern à un module qui tournerait effectivement en plusieurs répliques en
production/recette.
Port dédié, configurable
Le WebSocket écoute sur un port distinct du port HTTP principal du module, déclaré dans le bloc
real-time de la configuration du module (starter/ent-core.yaml) :
- name: fr.openent~exercizer~${EXERCIZER_VERSION}
config:
port: 8105 # HTTP principal (routes REST habituelles)
real-time:
port: 8106 # WebSocket applicatif dédié au pilotage
Si le bloc real-time est absent, Exercizer.initExercizer ne monte pas de
PilotageWebSocketController : les routes REST de pilotage restent pleinement fonctionnelles, seule
la diffusion en direct est indisponible (repli HTTP systématique côté client).
Le port n'est pas découvert dynamiquement : comme collaborative-wall (qui code en dur son port
9091 côté front dans useWsMode.ts), le frontend le connaît en dur
(PILOTAGE_WS_PORT = 8106 dans PilotageService.ts).
Repli HTTP (sondage)
Deux directives frontend ouvrent le WebSocket et basculent sur un sondage HTTP s'il n'est pas ouvert
sous 5 secondes (WS_OPEN_TIMEOUT_MS), avec un sondage toutes les 20 secondes
(POLL_INTERVAL_MS) tant que le canal reste indisponible :
- écran enseignant —
teacherDashboardPilotage.ts: au moindre événement WS reçu (horsmessage), redemande simplement l'état consolidé viaGET …/pilotage(le calcul reste serveur) ; en repli, interroge cette même route toutes les 20 s ; - écran élève —
subjectPerformCopyPilotage.ts: ne peut pas appelerGET …/pilotage(réservé au propriétaire du sujet), donc applique les événements WS en local avec les mêmes formules que le backend, et en repli réinterroge les deux endpoints élève déjà existants (GET /subjects-scheduled-by-subjects-copy/:offset,GET /subjects-copy) — aucun nouvel endpoint n'a été nécessaire côté élève.
// PilotageService.ts — construction de l'URL WS, dev vs prod
function buildUrl(): string {
return isLocalhost
? 'ws://' + hostname + ':' + PILOTAGE_WS_PORT + '/exercizer/pilotage/' + subjectScheduledId
: 'wss://' + host + '/exercizer/pilotage/realtime/' + subjectScheduledId;
}
Piège CSP : ${WS_URL} jamais défini avant ce chantier
ent-core.yaml référence depuis longtemps ${WS_URL} dans la directive connect-src de la
content-security-policy :
connect-src 'self' ${FRONTEND_DEV_URL:-} ${FRONTEND_URL} ${WS_URL} https://*.screeb.app …
Cette variable n'était définie dans aucun .env avant l'implémentation du pilotage — elle
figurait dans le gabarit CSP sans jamais avoir été câblée. Tant qu'aucun module n'ouvrait de
WebSocket applicatif depuis le navigateur, l'absence passait inaperçue (${WS_URL} se résolvait en
chaîne vide, sans effet visible). Avec le pilotage, une connexion new WebSocket(...) sans WS_URL
dans connect-src est bloquée par le navigateur :
Refused to connect to 'ws://localhost:8106/exercizer/pilotage/123' because it violates the following
Content Security Policy directive: "connect-src 'self' ...".
Le symptôme est trompeur : le port 8106 répond correctement en local (curl/test manuel du socket
suffit à s'en convaincre), la route REST de pilotage fonctionne, seul le navigateur refuse
silencieusement la connexion WS — rien côté serveur ne signale l'échec.
Fix appliqué — définir WS_URL dans les fichiers .env.localhost* du dépôt starter/ :
# starter/.env.localhost
WS_URL=ws://localhost:*
Un module qui ouvre un WebSocket depuis le navigateur (sur le modèle collaborative-wall /
chat-nats / exercizer) doit vérifier que WS_URL est bien défini dans l'environnement cible
(dev et production/recette), pas seulement que sa route/son port répond. ${WS_URL} est présent
dans le gabarit CSP d'ent-core.yaml depuis longtemps mais n'était câblé nulle part avant ce
chantier — un futur module qui s'appuierait sur sa seule présence dans le YAML sans vérifier sa
valeur effective retombera dans le même piège. En production, la valeur doit lister les hosts
WebSocket publics réels (schéma wss:// + host), pas le joker ws://localhost:* qui ne convient
qu'au développement local.
Modèle de données
Migration 036-add-pilotage-session-state.sql :
-- État de la séance elle-même (distinct de l'état de chaque copie élève).
ALTER TABLE exercizer.subject_scheduled
ADD COLUMN session_state VARCHAR(20) NOT NULL DEFAULT 'en_cours',
ADD CONSTRAINT subject_scheduled_session_state_check CHECK (session_state IN ('en_cours', 'en_pause')),
ADD COLUMN paused_at TIMESTAMP NULL,
ADD COLUMN paused_duration_seconds INTEGER NOT NULL DEFAULT 0;
-- Prolongation de temps et traçabilité d'une remise forcée, par copie élève.
ALTER TABLE exercizer.subject_copy
ADD COLUMN extra_time_minutes INTEGER NOT NULL DEFAULT 0,
ADD COLUMN is_forced_submit BOOLEAN NOT NULL DEFAULT FALSE;
| Colonne | Table | Rôle |
|---|---|---|
session_state | subject_scheduled | en_cours (défaut, comportement inchangé) / en_pause |
paused_at | subject_scheduled | horodatage du début de la pause en cours (NULL si pas en pause) |
paused_duration_seconds | subject_scheduled | cumul des pauses déjà terminées ; la pause en cours s'ajoute au calcul à la volée, pas à cette colonne |
extra_time_minutes | subject_copy | minutes ajoutées à l'échéance de cette copie (prolongation individuelle ou de classe) |
is_forced_submit | subject_copy | distingue une remise forc ée par l'enseignant d'une remise volontaire de l'élève |
Le temps restant n'est jamais stocké : il est recalculé à chaque appel
(dueDate + extraTimeMinutes×60000 + pausedDurationSeconds×1000 − now), aussi bien côté backend
(buildPilotageStateResponse) que côté frontend élève (subjectPerformCopyPilotage.ts,
computeRemainingLabel, mêmes formules), pour rester cohérent malgré les fuseaux horaires et une
pause en cours au moment du calcul.
Diagnostic
| Symptôme | Cause probable | Vérification |
|---|---|---|
WS refusé silencieusement, écran bascule en sondage (exercizer.pilotage.connection.poll) | WS_URL absent/mal formé dans la CSP | console navigateur : violates … connect-src |
| Écran élève ne reçoit rien mais le sondage rattrape sous 20 s | comportement attendu du repli, pas un bug | vérifier wsConnected dans le scope Angular |
| Élève sur une autre instance que l'enseignant ne reçoit jamais l'événement (WS ouvert des deux côtés) | plusieurs répliques exercizer sans affinité de session (limite mono-instance connue) | topologie de déploiement (nombre de pods/répliques) |
PUT …/pilotage/extend-time renvoie 400 pour une copie | copie déjà rendue (submitted/corrected), refus attendu | exercizer.pilotage.extend.invalid.minutes / réponse renderError |
| Bouton « Piloter la séance » absent dans l'onglet Correction | hors fenêtre horaire (begin_date/due_date) — comportement attendu, pas un bug | isPilotageAvailable() (teacherDashboardCorrectionCopyList.ts) |
Couverture de tests
Aucun test Playwright n'existe encore pour le pilotage à la date de cette documentation. La spec
fonctionnelle (SPEC-D3-animation-pilotage.md) prévoit un scénario par action, à deux contextes de
navigateur (un profil enseignant, un profil élève), pour vérifier que l'effet est visible côté élève
sans rechargement et que le backend refuse les actions incohérentes avec l'état de la copie. Tant que
ces tests ne sont pas écrits, seule la partie API a été exercée manuellement ; la vérification
navigateur complète (et les captures de la doc fonctionnelle) restent à produire.