From cba730838f46d9ea2ebae43fc93c6ed7d382e067 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Thu, 11 Jun 2026 15:56:54 +0100 Subject: [PATCH 01/16] Websocket implementation 2.0 --- backend/src/main/kotlin/dev/dres/DRES.kt | 2 +- .../main/kotlin/dev/dres/api/rest/RestApi.kt | 3 +- .../main/kotlin/dev/dres/run/RunExecutor.kt | 241 +++++++++--------- .../src/app/services/websocket.service.ts | 119 +++++++++ .../src/app/viewer/run-viewer.component.ts | 4 + 5 files changed, 249 insertions(+), 120 deletions(-) create mode 100644 frontend/src/app/services/websocket.service.ts diff --git a/backend/src/main/kotlin/dev/dres/DRES.kt b/backend/src/main/kotlin/dev/dres/DRES.kt index 783364c0..7592914a 100644 --- a/backend/src/main/kotlin/dev/dres/DRES.kt +++ b/backend/src/main/kotlin/dev/dres/DRES.kt @@ -117,7 +117,7 @@ object DRES { RunExecutor.init(store) /* Initialize EventStreamProcessor */ - EventStreamProcessor.register( /* Add handlers here */) + EventStreamProcessor.register(RunExecutor) EventStreamProcessor.init() /* Initialize Rest API. */ diff --git a/backend/src/main/kotlin/dev/dres/api/rest/RestApi.kt b/backend/src/main/kotlin/dev/dres/api/rest/RestApi.kt index 43bbb0b3..ca6c3e73 100644 --- a/backend/src/main/kotlin/dev/dres/api/rest/RestApi.kt +++ b/backend/src/main/kotlin/dev/dres/api/rest/RestApi.kt @@ -30,6 +30,7 @@ import dev.dres.api.rest.types.status.ErrorStatus import dev.dres.api.rest.types.users.ApiRole import dev.dres.data.model.config.Config import dev.dres.mgmt.cache.CacheManager +import dev.dres.run.RunExecutor import dev.dres.utilities.NamedThreadFactory import io.javalin.Javalin import io.javalin.apibuilder.ApiBuilder.* @@ -328,7 +329,7 @@ object RestApi { } } } - //ws("ws/run", runExecutor) + ws("ws/run", RunExecutor::accept) } } diff --git a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt index 2dc5b44c..7fa8d79f 100644 --- a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt +++ b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt @@ -1,15 +1,22 @@ package dev.dres.run - +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import dev.dres.api.rest.types.ViewerInfo +import dev.dres.api.rest.types.evaluation.websocket.ClientMessage +import dev.dres.api.rest.types.evaluation.websocket.ClientMessageType +import dev.dres.api.rest.types.evaluation.websocket.ServerMessage +import dev.dres.api.rest.types.evaluation.websocket.ServerMessageType import dev.dres.data.model.run.* import dev.dres.data.model.run.interfaces.EvaluationId +import dev.dres.run.eventstream.* import dev.dres.run.validation.interfaces.JudgementValidator +import io.javalin.websocket.WsConfig import io.javalin.websocket.WsContext import jetbrains.exodus.database.TransientEntityStore import kotlinx.dnq.query.* import org.slf4j.LoggerFactory import java.util.* +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import java.util.concurrent.Future import java.util.concurrent.locks.ReentrantReadWriteLock @@ -17,14 +24,15 @@ import kotlin.concurrent.read import kotlin.concurrent.write /** - * The execution environment for [RunManager]s + * The execution environment for [RunManager]s. * * @author Ralph Gasser - * @version 1.3.0 + * @version 1.4.0 */ -object RunExecutor { +object RunExecutor : StreamEventHandler { private val logger = LoggerFactory.getLogger(this.javaClass) + private val mapper = jacksonObjectMapper() /** Thread Pool Executor which is used to execute the [RunManager]s. */ private val executor = Executors.newCachedThreadPool() @@ -35,22 +43,23 @@ object RunExecutor { /** List of [JudgementValidator]s registered with this [RunExecutor]. */ private val judgementValidators = LinkedList() - /** List of [WsContext] that are currently connected. */ - private val connectedClients = HashMap() + /** WebSocket sessions currently connected, keyed by session ID. */ + private val connectedClients = ConcurrentHashMap() - /** List of session IDs that are currently observing an evaluation. */ - private val observingClients = HashMap>() + /** Session IDs currently observing each evaluation. */ + private val observingClients = HashMap>() -// /** Lock for accessing and changing all data structures related to WebSocket clients. */ -// private val clientLock = StampedLock() + /** Lock for WebSocket client data structures. */ + private val clientLock = ReentrantReadWriteLock() /** Lock for accessing and changing all data structures related to [RunManager]s. */ private val runManagerLock = ReentrantReadWriteLock() - /** Internal array of [Future]s for cleaning after [RunManager]s. See [RunExecutor.cleanerThread]*/ + /** Internal array of [Future]s for cleaning after [RunManager]s. */ private val results = HashMap, EvaluationId>() - /** Initializes the [RunExecutor] singleton. + /** + * Initializes the [RunExecutor] singleton. * * @param store The [TransientEntityStore] instance used to access persistent data. */ @@ -58,7 +67,7 @@ object RunExecutor { store.transactional { DbEvaluation.filter { (it.ended eq null) }.asSequence().forEach { evaluation -> try { - this.schedule(evaluation.toRunManager(store)) /* Re-schedule evaluations. */ + this.schedule(evaluation.toRunManager(store)) } catch (e: RuntimeException) { when (e) { is IllegalStateException, @@ -66,7 +75,6 @@ object RunExecutor { logger.error("Could not re-schedule previous run: ${e.message}") evaluation.ended = System.currentTimeMillis() } - else -> { logger.error("Fatal error during re-scheduling of previous run (${evaluation.evaluationId}): ${e.message}") throw e @@ -86,31 +94,23 @@ object RunExecutor { if (this.runManagers.containsKey(manager.id)) { throw IllegalArgumentException("This RunExecutor already runs a RunManager with the given ID ${manager.id}. The same RunManager cannot be executed twice!") } - - /* Setup all the required data structures. */ this.runManagers[manager.id] = manager this.observingClients[manager.id] = HashSet() - this.results[this.executor.submit(manager)] = manager.id /* Register future for cleanup thread. */ + this.results[this.executor.submit(manager)] = manager.id } - /** A thread that cleans after [RunManager] have finished. */ + /** A thread that cleans after [RunManager]s have finished. */ private val cleanerThread = Thread { while (!this@RunExecutor.executor.isShutdown) { -// var stamp = this@RunExecutor.runManagerLock.readLock() this@RunExecutor.runManagerLock.read { - //try { this@RunExecutor.results.entries.removeIf { entry -> val k = entry.key val v = entry.value if (k.isDone || k.isCancelled) { logger.info("RunManager $v (done = ${k.isDone}, cancelled = ${k.isCancelled}) will be removed!") -// stamp = this@RunExecutor.runManagerLock.tryConvertToWriteLock(stamp) -// if (stamp > -1L) { this@RunExecutor.runManagerLock.write { - /* Cleanup. */ this@RunExecutor.runManagers.remove(v) this@RunExecutor.observingClients.remove(v) - } true } else { @@ -118,9 +118,6 @@ object RunExecutor { } } } -// } finally { -// this@RunExecutor.runManagerLock.unlock(stamp) -// } Thread.sleep(500) } } @@ -132,60 +129,107 @@ object RunExecutor { this.cleanerThread.start() } -// /** -// * Callback for when registering this [RunExecutor] as handler for Javalin's WebSocket. -// * -// * @param t The [WsConfig] of the WebSocket endpoint. -// */ -// fun accept(t: WsConfig) { //TODO remove -// t.onConnect { -// /* Add WSContext to set of connected clients. */ -// this@RunExecutor.clientLock.write { -// val connection = WebSocketConnection(it) -// this.connectedClients[connection.httpSessionId] = connection -// } -// } -// t.onClose { -// val session = WebSocketConnection(it) -// this@RunExecutor.clientLock.write { -// val connection = WebSocketConnection(it) -// this.connectedClients.remove(connection.httpSessionId) -// this.runManagerLock.read { -// for (m in this.runManagers) { -// if (this.observingClients[m.key]?.remove(connection) == true) { -// m.value.wsMessageReceived(session, ClientMessage(m.key, ClientMessageType.UNREGISTER)) /* Send implicit unregister message associated with a disconnect. */ -// } -// } -// } -// } -// } -// t.onMessage { -// val message = try { -// it.messageAsClass() -// } catch (e: Exception) { -// logger.warn("Cannot parse WebSocket message: ${e.localizedMessage}") -// return@onMessage -// } -// val session = WebSocketConnection(it) -// logger.debug("Received WebSocket message: $message from ${it.session.policy}") -// this.runManagerLock.read { -// if (this.runManagers.containsKey(message.evaluationId)) { -// when (message.type) { -// ClientMessageType.ACK -> {} -// ClientMessageType.REGISTER -> this@RunExecutor.clientLock.write { this.observingClients[message.evaluationId]?.add(WebSocketConnection(it)) } -// ClientMessageType.UNREGISTER -> this@RunExecutor.clientLock.write { this.observingClients[message.evaluationId]?.remove(WebSocketConnection(it)) } -// ClientMessageType.PING -> it.send(ServerMessage(message.evaluationId, ServerMessageType.PING)) -// } -// this.runManagers[message.evaluationId]!!.wsMessageReceived(session, message) /* Forward message to RunManager. */ -// } -// } -// } -// } + /** + * Callback for registering this [RunExecutor] as Javalin's WebSocket handler. + * + * @param ws The [WsConfig] to configure. + */ + fun accept(ws: WsConfig) { + ws.onConnect { ctx -> + this.clientLock.write { + this.connectedClients[ctx.sessionId()] = ctx + } + logger.debug("WebSocket client connected: ${ctx.sessionId()}") + } + + ws.onClose { ctx -> + this.clientLock.write { + this.connectedClients.remove(ctx.sessionId()) + this.runManagerLock.read { + this.observingClients.values.forEach { it.remove(ctx.sessionId()) } + } + } + logger.debug("WebSocket client disconnected: ${ctx.sessionId()}") + } + + ws.onMessage { ctx -> + val message = try { + ctx.messageAsClass() + } catch (e: Exception) { + logger.warn("Cannot parse WebSocket message: ${e.localizedMessage}") + return@onMessage + } + logger.debug("Received WebSocket message: $message from ${ctx.sessionId()}") + this.runManagerLock.read { + if (this.runManagers.containsKey(message.evaluationId)) { + when (message.type) { + ClientMessageType.ACK -> {} + ClientMessageType.REGISTER -> this.clientLock.write { + this.observingClients[message.evaluationId]?.add(ctx.sessionId()) + } + ClientMessageType.UNREGISTER -> this.clientLock.write { + this.observingClients[message.evaluationId]?.remove(ctx.sessionId()) + } + ClientMessageType.PING -> ctx.send( + mapper.writeValueAsString(ServerMessage(message.evaluationId, ServerMessageType.PING)) + ) + } + } + } + } + + ws.onError { ctx -> + logger.error("WebSocket error for session ${ctx.sessionId()}: ${ctx.error()?.message}") + this.clientLock.write { + this.connectedClients.remove(ctx.sessionId()) + } + } + } /** - * Lists all [RunManager]s currently executed by this [RunExecutor] + * Broadcasts a [ServerMessage] to all clients observing the specified evaluation. * - * @return Immutable list of all [RunManager]s currently executed. + * @param message The [ServerMessage] to broadcast. + */ + fun broadcastWsMessage(message: ServerMessage) { + val json = try { + mapper.writeValueAsString(message) + } catch (e: Exception) { + logger.error("Failed to serialize ServerMessage: ${e.message}") + return + } + val observers = this.clientLock.read { + this.runManagerLock.read { + this.observingClients[message.evaluationId]?.toSet() ?: emptySet() + } + } + observers.forEach { sessionId -> + try { + this.connectedClients[sessionId]?.send(json) + } catch (e: Exception) { + logger.warn("Failed to send WebSocket message to session $sessionId: ${e.message}") + } + } + } + + /** + * Handles [StreamEvent]s from the [EventStreamProcessor] by broadcasting + * the appropriate [ServerMessage] to all registered observers. + */ + override fun handleStreamEvent(event: StreamEvent) { + when (event) { + is RunStartEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.COMPETITION_START)) + is RunEndEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.COMPETITION_END)) + is TaskStartEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.TASK_START, event.taskId)) + is TaskEndEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.TASK_END, event.taskId)) + is ScoreUpdateEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.COMPETITION_UPDATE)) + is SubmissionEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.TASK_UPDATED)) + else -> {} + } + } + + /** + * Lists all [RunManager]s currently executed by this [RunExecutor]. */ fun managers(): List = this.runManagerLock.read { return this.runManagers.values.toList() @@ -193,54 +237,15 @@ object RunExecutor { /** * Returns the [RunManager] for the given ID if such a [RunManager] exists. - * - * @param evaluationId The ID for which to return the [RunManager]. - * @return Optional [RunManager]. */ fun managerForId(evaluationId: EvaluationId): RunManager? = this.runManagerLock.read { return this.runManagers[evaluationId] } -// /** -// * Broadcasts a [ServerMessage] to all clients currently connected and observing a specific [RunManager]. -// * -// * @param message The [ServerMessage] that should be broadcast. -// */ -// fun broadcastWsMessage(message: ServerMessage) = this.clientLock.read { -// this.runManagerLock.read { -// this.connectedClients.values.filter { -// this.observingClients[message.evaluationId]?.contains(it) ?: false -// }.forEach { -// it.send(message) -// } -// } -// } - -// /** -// * Broadcasts a [ServerMessage] to all clients currently connected and observing a specific [RunManager] and are member of the specified team. -// * -// * @param teamId The [TeamId] of the relevant team -// * @param message The [ServerMessage] that should be broadcast. -// */ -// fun broadcastWsMessage(teamId: TeamId, message: ServerMessage) = this.clientLock.read { -// val manager = managerForId(message.evaluationId) -// if (manager != null) { -// val teamMembers = manager.template.teams.filter { it.id eq teamId }.flatMapDistinct { it.users }.asSequence().map { it.userId }.toList() -// this.runManagerLock.read { -// this.connectedClients.values.filter { -// this.observingClients[message.evaluationId]?.contains(it) ?: false && AccessManager.userIdForSession(it.sessionId) in teamMembers -// }.forEach { -// it.send(message) -// } -// } -// } -// } - /** - * Stops all runs + * Stops all runs. */ fun stop() { this.executor.shutdownNow() } - } diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts new file mode 100644 index 00000000..0b9798fa --- /dev/null +++ b/frontend/src/app/services/websocket.service.ts @@ -0,0 +1,119 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Observable, Subject, Subscription, interval } from 'rxjs'; +import { AppConfig } from '../app.config'; +import { IWsServerMessage } from '../model/ws/ws-server-message.interface'; +import { IWsClientMessage } from '../model/ws/ws-client-message.interface'; +import { ClientMessageType } from '../model/ws/client-message-type.enum'; + +@Injectable({ + providedIn: 'root', +}) +export class WebSocketService implements OnDestroy { + private socket: WebSocket | null = null; + private messageSubject = new Subject(); + private pingSubscription: Subscription | null = null; + private reconnectTimeout: ReturnType | null = null; + private currentEvaluationId: string | null = null; + + /** Observable stream of messages pushed by the server. */ + readonly messages$: Observable = this.messageSubject.asObservable(); + + constructor(private config: AppConfig) {} + + /** + * Connects to the WebSocket endpoint and registers for the given evaluation. + * Safe to call multiple times — no-ops if already connected to the same evaluation. + */ + connect(evaluationId: string): void { + if (this.socket?.readyState === WebSocket.OPEN && this.currentEvaluationId === evaluationId) { + return; + } + this.closeSocket(); + this.currentEvaluationId = evaluationId; + this.openConnection(); + } + + /** Disconnects from the WebSocket endpoint and stops reconnect attempts. */ + disconnect(): void { + this.currentEvaluationId = null; + this.closeSocket(); + } + + private openConnection(): void { + const url = this.config.webSocketUrl; + this.socket = new WebSocket(url); + + this.socket.onopen = () => { + this.send({ + evaluationId: this.currentEvaluationId, + type: ClientMessageType.ClientMessageTypeEnum.REGISTER, + }); + this.startPing(); + }; + + this.socket.onmessage = (event: MessageEvent) => { + try { + const message: IWsServerMessage = JSON.parse(event.data as string); + this.messageSubject.next(message); + } catch { + console.warn('[WebSocketService] Could not parse message:', event.data); + } + }; + + this.socket.onclose = () => { + this.stopPing(); + if (this.currentEvaluationId) { + this.reconnectTimeout = setTimeout(() => this.openConnection(), 5000); + } + }; + + this.socket.onerror = (error: Event) => { + console.error('[WebSocketService] WebSocket error:', error); + }; + } + + private send(message: IWsClientMessage): void { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)); + } + } + + private closeSocket(): void { + if (this.reconnectTimeout !== null) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.stopPing(); + if (this.socket) { + if (this.socket.readyState === WebSocket.OPEN) { + this.send({ + evaluationId: this.currentEvaluationId, + type: ClientMessageType.ClientMessageTypeEnum.UNREGISTER, + }); + this.socket.close(); + } + this.socket = null; + } + } + + private startPing(): void { + this.pingSubscription = interval(30_000).subscribe(() => { + if (this.currentEvaluationId) { + this.send({ + evaluationId: this.currentEvaluationId, + type: ClientMessageType.ClientMessageTypeEnum.PING, + }); + } + }); + } + + private stopPing(): void { + this.pingSubscription?.unsubscribe(); + this.pingSubscription = null; + } + + ngOnDestroy(): void { + this.disconnect(); + this.messageSubject.complete(); + } +} diff --git a/frontend/src/app/viewer/run-viewer.component.ts b/frontend/src/app/viewer/run-viewer.component.ts index 7bc7ab1e..d6c6c0f5 100644 --- a/frontend/src/app/viewer/run-viewer.component.ts +++ b/frontend/src/app/viewer/run-viewer.component.ts @@ -3,6 +3,8 @@ import { ActivatedRoute, ActivationEnd, Params, Router } from '@angular/router'; import { interval, merge, mergeMap, Observable, of, zip } from 'rxjs'; import { catchError, filter, map, pairwise, shareReplay, switchMap, tap } from 'rxjs/operators'; import { AppConfig } from '../app.config'; +import { WebSocketService } from '../services/websocket.service'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Position } from './model/run-viewer-position'; import { Widget } from './model/run-viewer-widgets'; @@ -84,6 +86,7 @@ export class RunViewerComponent implements OnInit, AfterViewInit, OnDestroy { private snackBar: MatSnackBar, private titleService: Title, private overlay: Overlay, + private wsService: WebSocketService, @Inject(DOCUMENT) private document: Document, private _viewContainerRef: ViewContainerRef ) { @@ -212,6 +215,7 @@ export class RunViewerComponent implements OnInit, AfterViewInit, OnDestroy { * Unregisters this RunViewerComponent on view destruction and cleans the WebSocket subscription. */ ngOnDestroy(): void { + this.wsService.disconnect(); this.titleService.setTitle('DRES'); } From b26cb9c6d7477c6303aeb0ee9a95411208dd11c8 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Thu, 11 Jun 2026 16:36:42 +0100 Subject: [PATCH 02/16] Unit Testing for websockets --- .../app/services/websocket.service.spec.ts | 220 ++++++++++++++++++ frontend/tsconfig.spec.json | 3 +- 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/services/websocket.service.spec.ts diff --git a/frontend/src/app/services/websocket.service.spec.ts b/frontend/src/app/services/websocket.service.spec.ts new file mode 100644 index 00000000..bf7c7b90 --- /dev/null +++ b/frontend/src/app/services/websocket.service.spec.ts @@ -0,0 +1,220 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { WebSocketService } from './websocket.service'; +import { AppConfig } from '../app.config'; +import { ClientMessageType } from '../model/ws/client-message-type.enum'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; +import { IWsServerMessage } from '../model/ws/ws-server-message.interface'; + +// ── Minimal WebSocket mock ──────────────────────────────────────────────────── + +class MockWebSocket { + static instance: MockWebSocket | null = null; + + readyState: number = WebSocket.CONNECTING; + sentMessages: string[] = []; + + onopen: ((e: Event) => void) | null = null; + onmessage: ((e: MessageEvent) => void) | null = null; + onclose: ((e: CloseEvent) => void) | null = null; + onerror: ((e: Event) => void) | null = null; + + constructor(public url: string) { + MockWebSocket.instance = this; + } + + send(data: string) { this.sentMessages.push(data); } + close() { this.readyState = WebSocket.CLOSED; this.onclose?.(new CloseEvent('close')); } + + /** Test helper — simulate a successful connection. */ + simulateOpen() { + this.readyState = WebSocket.OPEN; + this.onopen?.(new Event('open')); + } + + /** Test helper — simulate an incoming server message. */ + simulateMessage(msg: IWsServerMessage) { + this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(msg) })); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const WS_URL = 'ws://localhost:8080/api/ws/run'; + +function makeConfig(): Partial { + return { webSocketUrl: WS_URL } as Partial; +} + +function serverMsg(type: ServerMessageType.ServerMessageTypeEnum, evaluationId = 'eval-1'): IWsServerMessage { + return { evaluationId, type, timestamp: Date.now() }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('WebSocketService', () => { + let service: WebSocketService; + + beforeEach(() => { + MockWebSocket.instance = null; + (window as any).WebSocket = MockWebSocket; + + TestBed.configureTestingModule({ + providers: [ + WebSocketService, + { provide: AppConfig, useValue: makeConfig() }, + ], + }); + + service = TestBed.inject(WebSocketService); + }); + + afterEach(() => { + service.disconnect(); + }); + + // ── connect() ────────────────────────────────────────────────────────────── + + describe('connect', () => { + it('opens a WebSocket to the configured URL', () => { + service.connect('eval-1'); + expect(MockWebSocket.instance).not.toBeNull(); + expect(MockWebSocket.instance!.url).toBe(WS_URL); + }); + + it('sends REGISTER message on open', () => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + + const sent = JSON.parse(MockWebSocket.instance!.sentMessages[0]); + expect(sent.type).toBe(ClientMessageType.ClientMessageTypeEnum.REGISTER); + expect(sent.evaluationId).toBe('eval-1'); + }); + + it('is a no-op when already connected to the same evaluation', () => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + const first = MockWebSocket.instance; + + service.connect('eval-1'); + + expect(MockWebSocket.instance).toBe(first); + }); + + it('reconnects when called with a different evaluationId', () => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + const first = MockWebSocket.instance; + + service.connect('eval-2'); + + expect(MockWebSocket.instance).not.toBe(first); + }); + }); + + // ── messages$ ────────────────────────────────────────────────────────────── + + describe('messages$', () => { + it('emits parsed server messages', () => { + const received: IWsServerMessage[] = []; + service.messages$.subscribe((m) => received.push(m)); + + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + MockWebSocket.instance!.simulateMessage(serverMsg(ServerMessageType.ServerMessageTypeEnum.TASK_START)); + + expect(received.length).toBe(1); + expect(received[0].type).toBe(ServerMessageType.ServerMessageTypeEnum.TASK_START); + }); + + it('emits multiple consecutive messages', () => { + const received: IWsServerMessage[] = []; + service.messages$.subscribe((m) => received.push(m)); + + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + MockWebSocket.instance!.simulateMessage(serverMsg(ServerMessageType.ServerMessageTypeEnum.TASK_START)); + MockWebSocket.instance!.simulateMessage(serverMsg(ServerMessageType.ServerMessageTypeEnum.TASK_END)); + + expect(received.length).toBe(2); + }); + + it('silently ignores malformed JSON', () => { + const received: IWsServerMessage[] = []; + service.messages$.subscribe((m) => received.push(m)); + + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + MockWebSocket.instance!.onmessage?.(new MessageEvent('message', { data: 'not-json' })); + + expect(received.length).toBe(0); + }); + }); + + // ── disconnect() ─────────────────────────────────────────────────────────── + + describe('disconnect', () => { + it('sends UNREGISTER before closing', () => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + service.disconnect(); + + const msgs = MockWebSocket.instance!.sentMessages.map((s) => JSON.parse(s)); + const unregister = msgs.find((m) => m.type === ClientMessageType.ClientMessageTypeEnum.UNREGISTER); + expect(unregister).toBeDefined(); + }); + + it('sets socket to null after disconnect', () => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + service.disconnect(); + + expect((service as any).socket).toBeNull(); + }); + + it('does not attempt reconnect after explicit disconnect', fakeAsync(() => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + service.disconnect(); + + const socketAfterDisconnect = MockWebSocket.instance; + tick(6000); + + expect(MockWebSocket.instance).toBe(socketAfterDisconnect); + })); + }); + + // ── auto-reconnect ───────────────────────────────────────────────────────── + + describe('auto-reconnect', () => { + it('reconnects after 5 seconds when connection drops unexpectedly', fakeAsync(() => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + const first = MockWebSocket.instance; + + // Simulate unexpected server-side close + MockWebSocket.instance!.readyState = WebSocket.CLOSED; + MockWebSocket.instance!.onclose?.(new CloseEvent('close')); + + tick(5000); + + expect(MockWebSocket.instance).not.toBe(first); + expect(MockWebSocket.instance!.url).toBe(WS_URL); + })); + }); + + // ── ping ─────────────────────────────────────────────────────────────────── + + describe('ping', () => { + it('sends a PING message every 30 seconds', fakeAsync(() => { + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + + tick(30_000); + + const msgs = MockWebSocket.instance!.sentMessages.map((s) => JSON.parse(s)); + const ping = msgs.find((m) => m.type === ClientMessageType.ClientMessageTypeEnum.PING); + expect(ping).toBeDefined(); + expect(ping.evaluationId).toBe('eval-1'); + })); + }); +}); diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json index 430cf757..370d7efb 100644 --- a/frontend/tsconfig.spec.json +++ b/frontend/tsconfig.spec.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine", "node"] + "types": ["jasmine", "node"], + "skipLibCheck": true }, "files": ["src/test.ts", "src/polyfills.ts"], "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] From 9c5c0efdf502dbf2b8cd06785967802b3581ce02 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Thu, 11 Jun 2026 16:45:33 +0100 Subject: [PATCH 03/16] More testing --- .../app/viewer/run-viewer.component.spec.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 frontend/src/app/viewer/run-viewer.component.spec.ts diff --git a/frontend/src/app/viewer/run-viewer.component.spec.ts b/frontend/src/app/viewer/run-viewer.component.spec.ts new file mode 100644 index 00000000..c70fbe9d --- /dev/null +++ b/frontend/src/app/viewer/run-viewer.component.spec.ts @@ -0,0 +1,138 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Title } from '@angular/platform-browser'; +import { Overlay } from '@angular/cdk/overlay'; +import { of, Subject } from 'rxjs'; + +import { RunViewerComponent } from './run-viewer.component'; +import { AppConfig } from '../app.config'; +import { WebSocketService } from '../services/websocket.service'; +import { EvaluationService } from '../../../openapi'; +import { IWsServerMessage } from '../model/ws/ws-server-message.interface'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function wsMsg(type: ServerMessageType.ServerMessageTypeEnum): IWsServerMessage { + return { evaluationId: 'eval-1', type, timestamp: Date.now() }; +} + +const IRRELEVANT_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.PING, +]; + +const REFRESH_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_START, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, +]; + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('RunViewerComponent WebSocket wiring', () => { + let component: RunViewerComponent; + let wsService: jasmine.SpyObj; + let runService: jasmine.SpyObj; + let messages$: Subject; + + beforeEach(() => { + messages$ = new Subject(); + + wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { + messages$: messages$.asObservable(), + }); + + runService = jasmine.createSpyObj('EvaluationService', [ + 'getApiV2EvaluationByEvaluationIdState', + 'getApiV2EvaluationByEvaluationIdInfo', + ]); + runService.getApiV2EvaluationByEvaluationIdState.and.returnValue( + of({ taskStatus: 'RUNNING', taskTemplateId: 't1' } as any) + ); + runService.getApiV2EvaluationByEvaluationIdInfo.and.returnValue( + of({ name: 'Test Eval' } as any) + ); + + TestBed.configureTestingModule({ + declarations: [RunViewerComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: WebSocketService, useValue: wsService }, + { provide: EvaluationService, useValue: runService }, + { provide: ActivatedRoute, useValue: { params: of({ runId: 'eval-1' }), paramMap: of({ params: { runId: 'eval-1' }, get: () => 'eval-1' }) } }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate'], { url: '/viewer/eval-1' }) }, + { provide: AppConfig, useValue: { webSocketUrl: 'ws://localhost:8080/api/ws/run' } }, + { provide: MatSnackBar, useValue: jasmine.createSpyObj('MatSnackBar', ['open']) }, + { provide: Title, useValue: jasmine.createSpyObj('Title', ['setTitle']) }, + { provide: Overlay, useValue: {} }, + { provide: 'DOCUMENT', useValue: document }, + ], + }); + + const fixture = TestBed.createComponent(RunViewerComponent); + component = fixture.componentInstance; + }); + + // ── connect / disconnect ─────────────────────────────────────────────────── + + it('connects the WebSocket with the evaluationId on init', () => { + component.ngOnInit(); + expect(wsService.connect).toHaveBeenCalledWith('eval-1'); + }); + + it('disconnects the WebSocket on destroy', () => { + component.ngOnInit(); + component.ngOnDestroy(); + expect(wsService.disconnect).toHaveBeenCalled(); + }); + + // ── state refresh on WS message ──────────────────────────────────────────── + + REFRESH_TYPES.forEach((type) => { + it(`triggers a state fetch when ${type} message is received`, fakeAsync(() => { + component.ngOnInit(); + runService.getApiV2EvaluationByEvaluationIdState.calls.reset(); + + // Subscribe to state so the observable is active + component.state.subscribe(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runService.getApiV2EvaluationByEvaluationIdState).toHaveBeenCalledWith('eval-1'); + })); + }); + + // ── PING is ignored ──────────────────────────────────────────────────────── + + IRRELEVANT_TYPES.forEach((type) => { + it(`does not trigger a state fetch for ${type} message`, fakeAsync(() => { + component.ngOnInit(); + component.state.subscribe(); + runService.getApiV2EvaluationByEvaluationIdState.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runService.getApiV2EvaluationByEvaluationIdState).not.toHaveBeenCalled(); + })); + }); + + // ── no polling ───────────────────────────────────────────────────────────── + + it('does not fetch state on a timer when no WS message arrives', fakeAsync(() => { + component.ngOnInit(); + component.state.subscribe(); + runService.getApiV2EvaluationByEvaluationIdState.calls.reset(); + + tick(5000); // advance time — no interval should fire + + expect(runService.getApiV2EvaluationByEvaluationIdState).not.toHaveBeenCalled(); + })); +}); From d3b6c6a3c80f92e724a490b35e1e7c2f83e0b4dc Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Thu, 11 Jun 2026 16:50:44 +0100 Subject: [PATCH 04/16] Mapping tests --- .../main/kotlin/dev/dres/run/RunExecutor.kt | 24 +- .../dres/run/RunExecutorWebSocketTest.kt | 206 ++++++++++++++++++ 2 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt diff --git a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt index 7fa8d79f..27123259 100644 --- a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt +++ b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt @@ -212,20 +212,26 @@ object RunExecutor : StreamEventHandler { } } + /** + * Maps a [StreamEvent] to the [ServerMessage] that should be broadcast, or null + * if the event type requires no WebSocket notification. + */ + internal fun eventToMessage(event: StreamEvent): ServerMessage? = when (event) { + is RunStartEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_START) + is RunEndEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_END) + is TaskStartEvent -> ServerMessage(event.runId, ServerMessageType.TASK_START, event.taskId) + is TaskEndEvent -> ServerMessage(event.runId, ServerMessageType.TASK_END, event.taskId) + is ScoreUpdateEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_UPDATE) + is SubmissionEvent -> ServerMessage(event.runId, ServerMessageType.TASK_UPDATED) + else -> null + } + /** * Handles [StreamEvent]s from the [EventStreamProcessor] by broadcasting * the appropriate [ServerMessage] to all registered observers. */ override fun handleStreamEvent(event: StreamEvent) { - when (event) { - is RunStartEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.COMPETITION_START)) - is RunEndEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.COMPETITION_END)) - is TaskStartEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.TASK_START, event.taskId)) - is TaskEndEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.TASK_END, event.taskId)) - is ScoreUpdateEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.COMPETITION_UPDATE)) - is SubmissionEvent -> broadcastWsMessage(ServerMessage(event.runId, ServerMessageType.TASK_UPDATED)) - else -> {} - } + eventToMessage(event)?.let { broadcastWsMessage(it) } } /** diff --git a/backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt b/backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt new file mode 100644 index 00000000..df13e64a --- /dev/null +++ b/backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt @@ -0,0 +1,206 @@ +package dres.run + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import dev.dres.api.rest.types.evaluation.submission.ApiClientSubmission +import dev.dres.api.rest.types.evaluation.websocket.ServerMessage +import dev.dres.api.rest.types.evaluation.websocket.ServerMessageType +import dev.dres.api.rest.types.template.ApiEvaluationTemplate +import dev.dres.api.rest.types.template.tasks.ApiTaskTemplate +import dev.dres.run.RunExecutor +import dev.dres.run.eventstream.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow + +/** + * Unit tests for the WebSocket event handling in [RunExecutor]. + * + * Covers: + * 1. [RunExecutor.eventToMessage] — asserts the correct [ServerMessageType] and + * field values are produced for every handled [StreamEvent] subtype. + * 2. [ServerMessage] JSON serialisation — round-trip and field correctness. + * 3. Smoke tests — [RunExecutor.handleStreamEvent] does not throw for any event. + */ +class RunExecutorWebSocketTest { + + private val mapper = jacksonObjectMapper() + + private val runId = "eval-001" + private val taskId = "task-001" + + // ── eventToMessage mapping ──────────────────────────────────────────────── + + @Test + fun `RunStartEvent maps to COMPETITION_START`() { + val msg = RunExecutor.eventToMessage(RunStartEvent(runId, minimalTemplate())) + assertNotNull(msg) + assertEquals(ServerMessageType.COMPETITION_START, msg!!.type) + assertEquals(runId, msg.evaluationId) + assertNull(msg.taskId) + } + + @Test + fun `RunEndEvent maps to COMPETITION_END`() { + val msg = RunExecutor.eventToMessage(RunEndEvent(runId)) + assertNotNull(msg) + assertEquals(ServerMessageType.COMPETITION_END, msg!!.type) + assertEquals(runId, msg.evaluationId) + assertNull(msg.taskId) + } + + @Test + fun `TaskStartEvent maps to TASK_START with correct taskId`() { + val msg = RunExecutor.eventToMessage(TaskStartEvent(runId, taskId, minimalTaskTemplate())) + assertNotNull(msg) + assertEquals(ServerMessageType.TASK_START, msg!!.type) + assertEquals(runId, msg.evaluationId) + assertEquals(taskId, msg.taskId) + } + + @Test + fun `TaskEndEvent maps to TASK_END with correct taskId`() { + val msg = RunExecutor.eventToMessage(TaskEndEvent(runId, taskId)) + assertNotNull(msg) + assertEquals(ServerMessageType.TASK_END, msg!!.type) + assertEquals(runId, msg.evaluationId) + assertEquals(taskId, msg.taskId) + } + + @Test + fun `ScoreUpdateEvent maps to COMPETITION_UPDATE`() { + val msg = RunExecutor.eventToMessage(ScoreUpdateEvent(runId, "scoreboard", emptyMap())) + assertNotNull(msg) + assertEquals(ServerMessageType.COMPETITION_UPDATE, msg!!.type) + assertEquals(runId, msg.evaluationId) + } + + @Test + fun `SubmissionEvent maps to TASK_UPDATED`() { + val msg = RunExecutor.eventToMessage( + SubmissionEvent("session-1", runId, ApiClientSubmission(answerSets = emptyList())) + ) + assertNotNull(msg) + assertEquals(ServerMessageType.TASK_UPDATED, msg!!.type) + assertEquals(runId, msg.evaluationId) + } + + @Test + fun `unhandled event type maps to null`() { + val msg = RunExecutor.eventToMessage(InvalidRequestEvent("session-1", runId, "bad data")) + assertNull(msg) + } + + @Test + fun `every handled event type carries the correct evaluationId`() { + val events: List = listOf( + RunStartEvent(runId, minimalTemplate()), + RunEndEvent(runId), + TaskStartEvent(runId, taskId, minimalTaskTemplate()), + TaskEndEvent(runId, taskId), + ScoreUpdateEvent(runId, "board", emptyMap()), + SubmissionEvent("s", runId, ApiClientSubmission(answerSets = emptyList())) + ) + events.forEach { event -> + val msg = RunExecutor.eventToMessage(event) + assertNotNull(msg, "Expected non-null message for ${event::class.simpleName}") + assertEquals(runId, msg!!.evaluationId, "Wrong evaluationId for ${event::class.simpleName}") + } + } + + // ── ServerMessage JSON serialisation ────────────────────────────────────── + + @Test + fun `ServerMessage round-trips through JSON`() { + val original = ServerMessage(runId, ServerMessageType.TASK_START, taskId) + val decoded: ServerMessage = mapper.readValue(mapper.writeValueAsString(original)) + + assertEquals(original.evaluationId, decoded.evaluationId) + assertEquals(original.type, decoded.type) + assertEquals(original.taskId, decoded.taskId) + } + + @Test + fun `ServerMessage JSON contains evaluationId, type and timestamp`() { + val tree = mapper.readTree( + mapper.writeValueAsString(ServerMessage(runId, ServerMessageType.COMPETITION_END)) + ) + assertEquals(runId, tree["evaluationId"].asText()) + assertEquals("COMPETITION_END", tree["type"].asText()) + assertNotNull(tree["timestamp"]) + } + + @Test + fun `ServerMessage without taskId serialises taskId as null`() { + val tree = mapper.readTree( + mapper.writeValueAsString(ServerMessage(runId, ServerMessageType.COMPETITION_START)) + ) + assertTrue(tree["taskId"].isNull) + } + + @Test + fun `ServerMessage with taskId serialises taskId correctly`() { + val tree = mapper.readTree( + mapper.writeValueAsString(ServerMessage(runId, ServerMessageType.TASK_START, taskId)) + ) + assertEquals(taskId, tree["taskId"].asText()) + } + + @Test + fun `all ServerMessageTypes serialise without error`() { + ServerMessageType.entries.forEach { type -> + assertDoesNotThrow("Failed for type $type") { + mapper.writeValueAsString(ServerMessage(runId, type)) + } + } + } + + // ── handleStreamEvent smoke tests ───────────────────────────────────────── + + @Test + fun `handleStreamEvent does not throw for any event type`() { + val events: List = listOf( + RunStartEvent(runId, minimalTemplate()), + RunEndEvent(runId), + TaskStartEvent(runId, taskId, minimalTaskTemplate()), + TaskEndEvent(runId, taskId), + ScoreUpdateEvent(runId, "board", emptyMap()), + SubmissionEvent("s", runId, ApiClientSubmission(answerSets = emptyList())), + InvalidRequestEvent("s", runId, "bad data") + ) + events.forEach { event -> + assertDoesNotThrow("handleStreamEvent threw for ${event::class.simpleName}") { + RunExecutor.handleStreamEvent(event) + } + } + } + + // ── Fixture helpers ─────────────────────────────────────────────────────── + + private fun minimalTemplate() = ApiEvaluationTemplate( + id = runId, + name = "Test Evaluation", + description = null, + created = null, + modified = null, + taskTypes = emptyList(), + taskGroups = emptyList(), + tasks = emptyList(), + teams = emptyList(), + teamGroups = emptyList(), + judges = emptyList(), + viewers = emptyList() + ) + + private fun minimalTaskTemplate() = ApiTaskTemplate( + id = taskId, + name = "Task 1", + taskGroup = "group-1", + taskType = "KIS", + duration = 300L, + collectionId = "col-1", + targets = emptyList(), + hints = emptyList(), + comment = null + ) +} From 46505f149600bf34301eace01667deb5c763e5d1 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Fri, 12 Jun 2026 17:01:21 +0100 Subject: [PATCH 05/16] replacing the two judgment viewer interval with websocket events --- .../judgement/judgement-viewer.component.ts | 294 +++++++++--------- 1 file changed, 155 insertions(+), 139 deletions(-) diff --git a/frontend/src/app/judgement/judgement-viewer.component.ts b/frontend/src/app/judgement/judgement-viewer.component.ts index ac91c6f2..0b9ea699 100644 --- a/frontend/src/app/judgement/judgement-viewer.component.ts +++ b/frontend/src/app/judgement/judgement-viewer.component.ts @@ -1,15 +1,17 @@ -import { AfterViewInit, Component, HostListener, Input, OnDestroy, ViewChild } from '@angular/core'; -import { BehaviorSubject, interval, Observable, of, Subscription, timer } from 'rxjs'; -import { ActivatedRoute, Router } from '@angular/router'; -import { catchError, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; -import { JudgementMediaViewerComponent } from './judgement-media-viewer.component'; -import { MatSnackBar } from '@angular/material/snack-bar'; +import {AfterViewInit, Component, HostListener, Input, OnDestroy, ViewChild} from '@angular/core'; +import {BehaviorSubject, merge, Observable, of, Subscription, timer} from 'rxjs'; +import {ActivatedRoute, Router} from '@angular/router'; +import {catchError, filter, map, switchMap, take, withLatestFrom} from 'rxjs/operators'; +import {JudgementMediaViewerComponent} from './judgement-media-viewer.component'; +import {MatSnackBar} from '@angular/material/snack-bar'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { animate, keyframes, state, style, transition, trigger } from '@angular/animations'; -import { MatDialog } from '@angular/material/dialog'; -import { JudgementDialogComponent } from './judgement-dialog/judgement-dialog.component'; -import { JudgementDialogContent } from './judgement-dialog/judgement-dialog-content.model'; -import { ApiJudgement, ApiJudgementRequest, ApiVerdictStatus, JudgementService } from '../../../openapi'; +import {animate, keyframes, state, style, transition, trigger} from '@angular/animations'; +import {MatDialog} from '@angular/material/dialog'; +import {JudgementDialogComponent} from './judgement-dialog/judgement-dialog.component'; +import {JudgementDialogContent} from './judgement-dialog/judgement-dialog-content.model'; +import {ApiJudgement, ApiJudgementRequest, ApiVerdictStatus, JudgementService} from '../../../openapi'; +import {WebSocketService} from '../services/websocket.service'; +import {ServerMessageType} from '../model/ws/server-message-type.enum'; /** * This component subscribes to the websocket for submissions. @@ -61,41 +63,45 @@ export class JudgementViewerComponent implements AfterViewInit, OnDestroy { private deadMansSwitchSub: Subscription; private deadMansSwitchTime = 0; - constructor( - private judgementService: JudgementService, - private activeRoute: ActivatedRoute, - private snackBar: MatSnackBar, - private router: Router, - private dialog: MatDialog - ) {} + constructor( + private judgementService: JudgementService, + private activeRoute: ActivatedRoute, + private snackBar: MatSnackBar, + private router: Router, + private dialog: MatDialog, + private wsService: WebSocketService + ) { + } - ngAfterViewInit(): void { - const dialogRef = this.dialog.open(JudgementDialogComponent, { - width: '400px', - data: { - title: 'Judgement Intro', - body: - '

Hello Judge

\n' + - '

\n' + - ' Once you clicked any of the button below, the judging view will open.\n' + - ' Your task will be to judge, whether the shown video segment fulfills the given description or not.\n' + - " In case of doubt, you also can opt for don't know.\n" + - '

\n' + - '

\n' + - ' Information:\n' + - ' Red border means this is for context only: You shall not judge what is in a red border.\n' + - ' The colour change indicates a new task, hence a new description. Read it before you make a verdict.\n' + - '

' + - '

\n' + - ' Thank you for being a fair Judge!\n' + - '

', - } as JudgementDialogContent, - }); - dialogRef.afterClosed().subscribe((_) => { - this.init(); - this.initialiseDeadMansSwitch(); - }); - } + ngAfterViewInit(): void { + this.activeRoute.params.pipe(map((p) => p.runId), take(1)).subscribe((id) => this.wsService.connect(id)); + + const dialogRef = this.dialog.open(JudgementDialogComponent, { + width: '400px', + data: { + title: 'Judgement Intro', + body: + '

Hello Judge

\n' + + '

\n' + + ' Once you clicked any of the button below, the judging view will open.\n' + + ' Your task will be to judge, whether the shown video segment fulfills the given description or not.\n' + + ' In case of doubt, you also can opt for don\'t know.\n' + + '

\n' + + '

\n' + + ' Information:\n' + + ' Red border means this is for context only: You shall not judge what is in a red border.\n' + + ' The colour change indicates a new task, hence a new description. Read it before you make a verdict.\n' + + '

' + + '

\n' + + ' Thank you for being a fair Judge!\n' + + '

', + } as JudgementDialogContent, + }); + dialogRef.afterClosed().subscribe((_) => { + this.init(); + this.initialiseDeadMansSwitch(); + }); + } @HostListener('document:keypress', ['$event']) handleKeyboardEvent(event: KeyboardEvent) { @@ -122,107 +128,117 @@ export class JudgementViewerComponent implements AfterViewInit, OnDestroy { } } - init(): void { - /* Subscription and current run id */ - this.runId = this.activeRoute.params.pipe(map((p) => p.runId)); - /* Poll for score status in a given interval */ - this.statusSub = interval(this.pollingFrequency) - .pipe( - withLatestFrom(this.runId), - switchMap(([i, runId]) => { - return this.judgementService.getApiV2EvaluationByEvaluationIdJudgeStatus(runId).pipe( - catchError((err) => { - console.log('Error in JudgeStatus'); - console.log(err); - return of(null); - }), - filter((x) => x !== null) - ); - }), - filter((x) => x != null) - ) - .subscribe((value) => { - let pending = 0; - let open = 0; - value.forEach((j) => { - pending += j.pending; - open += j.open; - }); - this.updateProgress(pending, open); - }); + init(): void { + /* Subscription and current run id */ + this.runId = this.activeRoute.params.pipe(map((p) => p.runId)); - /* Poll for score updates in a given interval. */ - this.requestSub = interval(this.pollingFrequency) - .pipe( - withLatestFrom(this.runId), - switchMap(([i, runId]) => { - /* Stop polling while judgment is ongooing */ - if (this.runId && !this.isJudgmentAvailable) { - return this.judgementService.getApiV2EvaluationByEvaluationIdJudgeNext(runId, 'response').pipe( - map((req: HttpResponse) => { - if (req.status === 202) { - this.noJudgementMessage = 'There is currently no submission awaiting judgement.'; - /* Don't penalise if there's nothing to do*/ - this.deadMansSwitchTime = 0; - this.isJudgmentAvailable = false; - return null; - } else { - return req.body; - } - }), - catchError((err) => { - const httperr = err as HttpErrorResponse; - if (httperr) { - if (httperr.status === 404) { - this.router.navigate(['/run/list']); - } else if (httperr.status === 408) { - this.snackBar.open(`You were inactive for too long and the verdict was not accepted by teh server`, null, { - duration: 2000, - }); - return of(null); - } + /* Trigger on relevant WebSocket events, with a 30s fallback poll in case the socket drops. */ + const wsRefresh$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ].includes(msg.type)) + ); + const trigger$ = merge(timer(0, 30_000), wsRefresh$); + + /* Fetch judge status on websocket event or fallback interval. */ + this.statusSub = trigger$ + .pipe( + withLatestFrom(this.runId), + switchMap(([_, runId]) => { + return this.judgementService.getApiV2EvaluationByEvaluationIdJudgeStatus(runId).pipe( + catchError((err) => { + console.log('Error in JudgeStatus'); + console.log(err); + return of(null); + }), + filter((x) => x !== null) + ); + }), + filter((x) => x != null) + ) + .subscribe((value) => { + let pending = 0; + let open = 0; + value.forEach((j) => { + pending += j.pending; + open += j.open; + }); + this.updateProgress(pending, open); + }); + + /* Fetch next judgement request on websocket event or fallback interval. */ + this.requestSub = trigger$ + .pipe( + withLatestFrom(this.runId), + switchMap(([_, runId]) => { + /* Only fetch when no judgement is currently in progress. */ + if (this.runId && !this.isJudgmentAvailable) { + return this.judgementService.getApiV2EvaluationByEvaluationIdJudgeNext(runId, 'response').pipe( + map((req: HttpResponse) => { + if (req.status === 202) { + this.noJudgementMessage = 'There is currently no submission awaiting judgement.'; + /* Don't penalise if there's nothing to do*/ + this.deadMansSwitchTime = 0; + this.isJudgmentAvailable = false; + return null; + } else { + return req.body; + } + }), + catchError((err) => { + const httperr = err as HttpErrorResponse; + if (httperr) { + if (httperr.status === 404) { + this.router.navigate(['/run/list']); + } else if (httperr.status === 408) { + this.snackBar.open(`You were inactive for too long and the verdict was not accepted by teh server`, null, {duration: 2000}); + return of(null); + } + } + console.log('[Judgem.View] Error in getJudgeNext: '); + console.log(err); + return of(null); + }) + ); + } else { + return of(null); + } + }), + filter((x) => x != null) + ) + .subscribe((req) => { + console.log('[Judgem.View] Received request'); + console.log(req); + if (this.prevDescHash) { + this.isNewJudgementDesc = this.prevDescHash !== this.hashCode(req.taskDescription); + console.log('new: ' + this.isNewJudgementDesc); + if (this.isNewJudgementDesc) { + this.status = 'fresh'; + window.scroll(0,0); + } else { + this.status = 'known'; + } } - console.log('[Judgem.View] Error in getJudgeNext: '); - console.log(err); - return of(null); - }) - ); - } else { - return of(null); - } - }), - filter((x) => x != null) - ) - .subscribe((req) => { - console.log('[Judgem.View] Received request'); - console.log(req); - if (this.prevDescHash) { - this.isNewJudgementDesc = this.prevDescHash !== this.hashCode(req.taskDescription); - console.log('new: ' + this.isNewJudgementDesc); - if (this.isNewJudgementDesc) { - this.status = 'fresh'; - window.scroll(0, 0); - } else { - this.status = 'known'; - } - } - this.judgementRequest = req; - this.observableJudgementRequest.next(req); - this.isJudgmentAvailable = true; - this.prevDescHash = this.hashCode(this.judgementRequest.taskDescription); - }); - } + this.judgementRequest = req; + this.observableJudgementRequest.next(req); + this.isJudgmentAvailable = true; + this.prevDescHash = this.hashCode(this.judgementRequest.taskDescription); + }); + } allAnswers() { return this.observableJudgementRequest?.value?.answerSet?.answers || []; } - /** - * - */ - ngOnDestroy(): void { - this.stopAll(); - } + /** + * + */ + ngOnDestroy(): void { + this.wsService.disconnect(); + this.stopAll(); + } public updateProgress(pending: number, open: number) { this.openSubmissions.next(Math.round(open)); From 7cb0333daa1b7f660a394f4835dca574b1ab5cd4 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Sun, 14 Jun 2026 14:59:06 +0100 Subject: [PATCH 06/16] websockets in submission lists --- .../submissions-list.component.ts | 131 ++++++++++-------- .../judgement-voting-viewer.component.ts | 28 +++- .../src/app/run/run-admin-view.component.ts | 51 ++++--- .../run-async-admin-view.component.ts | 26 +++- 4 files changed, 149 insertions(+), 87 deletions(-) diff --git a/frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.ts b/frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.ts index db37d092..3c003354 100644 --- a/frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.ts +++ b/frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.ts @@ -10,10 +10,12 @@ import { ApiTaskTemplate, EvaluationAdministratorService, EvaluationService, - TemplateService, -} from '../../../../../../openapi'; -import { AppConfig } from '../../../../app.config'; -import { catchError, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; + TemplateService +} from "../../../../../../openapi"; +import { AppConfig } from "../../../../app.config"; +import { catchError, filter, map, switchMap, take, withLatestFrom } from "rxjs/operators"; +import { WebSocketService } from "../../../../services/websocket.service"; +import { ServerMessageType } from "../../../../model/ws/server-message-type.enum"; @Component({ selector: 'app-submissions-list', @@ -44,65 +46,76 @@ export class SubmissionsListComponent implements AfterViewInit, OnDestroy { private sub: Subscription; - constructor( - private snackBar: MatSnackBar, - private dialog: MatDialog, - private activeRoute: ActivatedRoute, - private evalService: EvaluationService, - private evaluationService: EvaluationAdministratorService, - private templateService: TemplateService, - public config: AppConfig - ) { - this.runId = this.activeRoute.paramMap.pipe(map((params) => params.get('runId'))); - this.taskId = this.activeRoute.paramMap.pipe(map((params) => params.get('taskId'))); - } + constructor( + private snackBar: MatSnackBar, + private dialog: MatDialog, + private activeRoute: ActivatedRoute, + private evalService: EvaluationService, + private evaluationService: EvaluationAdministratorService, + private templateService: TemplateService, + public config: AppConfig, + private wsService: WebSocketService, + ) { + this.runId = this.activeRoute.paramMap.pipe(map((params) => params.get('runId'))); + this.taskId = this.activeRoute.paramMap.pipe(map((params) => params.get('taskId'))); + } ngAfterViewInit(): void { - this.subscription = merge( - timer(0, this.pollingFrequencyInSeconds * 1000).pipe(filter((_) => this.polling)), - this.refreshSubject - ) - .pipe( - withLatestFrom(this.runId, this.taskId), - switchMap(([_, r, t]) => this.evaluationService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId(r, t)), - catchError((err, o) => { - console.error(`[SubmissionList] Error occurred while laoding submissions: ${err?.message}`); - this.snackBar.open(`Error: Couldn't load submissions for reason: ${err?.message}`, null, { duration: 5000 }); - return of([]); - }) - ) - .subscribe((s: ApiSubmissionInfo[]) => { - /* The assumption here is, that task runs do not magically disappear */ - if (this.taskRunIds.length < s.length) { - s.forEach((si) => { - if (!this.taskRunIds.includes(si.taskId)) { - this.taskRunIds.push(si.taskId); - this.submissionInfosByRunId.set(si.taskId, si); - } - }); - } - }); - this.sub = this.runId - .pipe( - switchMap((r) => this.evalService.getApiV2EvaluationByEvaluationIdInfo(r)), - catchError((error, o) => { - console.log(`[SubmissionList] Error occurred while loading template information: ${error?.message}`); - this.snackBar.open(`Error: Couldn't load template information: ${error?.message}`, null, { duration: 5000 }); - return of(null); - }), - filter((r) => r != null), - switchMap((evalInfo) => this.templateService.getApiV2TemplateByTemplateIdTaskList(evalInfo.templateId)), - withLatestFrom(this.taskId) - ) - .subscribe(([taskList, taskId]) => { - this.taskTemplate = taskList.find((t) => t.id === taskId); - }); + this.runId.pipe(take(1)).subscribe((id) => this.wsService.connect(id)); + + /* Refresh on new/updated submissions pushed via WebSocket, in addition to manual refresh and periodic polling. */ + const wsRefresh$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ].includes(msg.type)) + ); + + this.subscription = merge( + timer(0, this.pollingFrequencyInSeconds * 1000) + .pipe(filter((_) => this.polling)), + this.refreshSubject, + wsRefresh$) + .pipe( + withLatestFrom(this.runId, this.taskId), + switchMap(([_,r,t]) => this.evaluationService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId(r,t)), + catchError((err, o) => { + console.error(`[SubmissionList] Error occurred while laoding submissions: ${err?.message}`); + this.snackBar.open(`Error: Couldn't load submissions for reason: ${err?.message}`, null, {duration: 5000}); + return of([]); + }) + ) + .subscribe((s: ApiSubmissionInfo[]) => { + /* The assumption here is, that task runs do not magically disappear */ + if(this.taskRunIds.length < s.length){ + s.forEach((si) => { + if(!this.taskRunIds.includes(si.taskId)){ + this.taskRunIds.push(si.taskId); + this.submissionInfosByRunId.set(si.taskId, si); + } + }) + } + }) + this.sub = this.runId.pipe( + switchMap((r) => this.evalService.getApiV2EvaluationByEvaluationIdInfo(r)), + catchError((error, o) => { + console.log(`[SubmissionList] Error occurred while loading template information: ${error?.message}`); + this.snackBar.open(`Error: Couldn't load template information: ${error?.message}`, null, {duration: 5000}); + return of(null); + }), + filter((r) => r != null), + switchMap((evalInfo) => this.templateService.getApiV2TemplateByTemplateIdTaskList(evalInfo.templateId)), + withLatestFrom(this.taskId) + ).subscribe(([taskList, taskId]) => { + this.taskTemplate = taskList.find((t) => t.id === taskId) + }); } ngOnDestroy(): void { - this.subscription?.unsubscribe(); - this.subscription = null; - this.sub?.unsubscribe(); - this.sub = null; + this.wsService.disconnect(); + this.subscription?.unsubscribe(); + this.subscription = null; + this.sub?.unsubscribe(); + this.sub = null; } trackById(_: number, item: ApiSubmissionInfo) { diff --git a/frontend/src/app/judgement/judgement-voting-viewer.component.ts b/frontend/src/app/judgement/judgement-voting-viewer.component.ts index 36ebdbd5..968f33b3 100644 --- a/frontend/src/app/judgement/judgement-voting-viewer.component.ts +++ b/frontend/src/app/judgement/judgement-voting-viewer.component.ts @@ -1,12 +1,14 @@ import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { BehaviorSubject, interval, Observable, of, Subscription } from 'rxjs'; -import { catchError, filter, map, switchMap, withLatestFrom } from 'rxjs/operators'; +import { BehaviorSubject, merge, Observable, of, Subscription, timer } from 'rxjs'; +import { catchError, filter, map, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; import { AppConfig } from '../app.config'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { MatSnackBar } from '@angular/material/snack-bar'; import { JudgementMediaViewerComponent } from './judgement-media-viewer.component'; -import { ApiJudgementRequest, JudgementService } from '../../../openapi'; +import {ApiJudgementRequest, JudgementService} from '../../../openapi'; +import { WebSocketService } from '../services/websocket.service'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; @Component({ selector: 'app-judgement-voting-viewer', @@ -33,18 +35,29 @@ export class JudgementVotingViewerComponent implements OnInit, OnDestroy { private activeRoute: ActivatedRoute, private config: AppConfig, private snackBar: MatSnackBar, - private router: Router + private router: Router, + private wsService: WebSocketService ) {} ngOnInit(): void { this.runId = this.activeRoute.params.pipe(map((p) => p.runId)); this.voteClientPath = this.runId.pipe(map((id) => this.config.resolveUrl(`vote#${id}`))); - /* Poll for score updates in a given interval. */ - this.requestSub = interval(this.pollingFrequency) + this.runId.pipe(take(1)).subscribe((id) => this.wsService.connect(id)); + + const wsRefresh$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ].includes(msg.type)) + ); + + /* Fetch next vote request on websocket event or 30s fallback poll. */ + this.requestSub = merge(timer(0, 30_000), wsRefresh$) .pipe( withLatestFrom(this.runId), - switchMap(([i, runId]) => { + switchMap(([_, runId]) => { if (this.runId) { return this.judgementService.getApiV2EvaluationByEvaluationIdVoteNext(runId, 'response').pipe( map((req: HttpResponse) => { @@ -97,6 +110,7 @@ export class JudgementVotingViewerComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.wsService.disconnect(); this.requestSub.unsubscribe(); this.requestSub = null; if (this.judgePlayer) { diff --git a/frontend/src/app/run/run-admin-view.component.ts b/frontend/src/app/run/run-admin-view.component.ts index 61441052..67fb4a09 100644 --- a/frontend/src/app/run/run-admin-view.component.ts +++ b/frontend/src/app/run/run-admin-view.component.ts @@ -1,8 +1,10 @@ -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { AppConfig } from '../app.config'; -import { combineLatest, merge, mergeMap, Observable, of, Subject, timer } from 'rxjs'; -import { catchError, filter, map, shareReplay, switchMap } from 'rxjs/operators'; +import { combineLatest, merge, mergeMap, Observable, of, Subject, timer} from 'rxjs'; +import { catchError, filter, map, shareReplay, switchMap, take } from "rxjs/operators"; +import { WebSocketService } from '../services/websocket.service'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatDialog } from '@angular/material/dialog'; import { RunInfoOverviewTuple } from './admin-run-list.component'; @@ -29,7 +31,8 @@ export interface CombinedRun { styleUrls: ['./run-admin-view.component.scss'], standalone: false, }) -export class RunAdminViewComponent { +export class RunAdminViewComponent implements OnInit, OnDestroy { + private static VIEWER_POLLING_FREQUENCY = 3 * 1000; //ms private static STATE_POLLING_FREQUENCY = 1 * 1000; //ms private static OVERVIEW_POLLING_FREQUENCY = 5 * 1000; //ms @@ -52,8 +55,20 @@ export class RunAdminViewComponent { private competitionService: TemplateService, private runAdminService: EvaluationAdministratorService, private snackBar: MatSnackBar, - private dialog: MatDialog + private dialog: MatDialog, + private wsService: WebSocketService ) { + const wsRefresh$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, + ].includes(msg.type)) + ); + this.runId = this.activeRoute.params.pipe(map((a) => a.runId)); this.run = this.runId.pipe( switchMap((runId) => @@ -73,9 +88,7 @@ export class RunAdminViewComponent { }), filter((q) => q != null) ), - merge(timer(0, RunAdminViewComponent.STATE_POLLING_FREQUENCY), this.refreshSubject).pipe( - switchMap((index) => this.runService.getApiV2EvaluationByEvaluationIdState(runId)) - ), + merge(timer(0, 30_000), this.refreshSubject, wsRefresh$).pipe(switchMap(() => this.runService.getApiV2EvaluationByEvaluationIdState(runId))), ]) ), map(([i, s]) => { @@ -148,8 +161,8 @@ export class RunAdminViewComponent { }), filter((q) => q != null) ), - merge(timer(0, RunAdminViewComponent.OVERVIEW_POLLING_FREQUENCY), this.refreshSubject).pipe( - switchMap((index) => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + merge(timer(0, 30_000), this.refreshSubject, wsRefresh$).pipe( + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) ), ]) ), @@ -160,11 +173,7 @@ export class RunAdminViewComponent { ); this.viewers = this.runId.pipe( - mergeMap((runId) => - timer(0, RunAdminViewComponent.VIEWER_POLLING_FREQUENCY).pipe( - switchMap((i) => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)) - ) - ) + mergeMap((runId) => merge(timer(0, 30_000), wsRefresh$).pipe(switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)))) ); this.teams = this.run.pipe( @@ -175,8 +184,16 @@ export class RunAdminViewComponent { shareReplay({ bufferSize: 1, refCount: true }) ); } - stateFromCombined(combined: Observable): Observable { - return combined.pipe(map((c) => c.state)); + ngOnInit(): void { + this.runId.pipe(take(1)).subscribe((id) => this.wsService.connect(id)); + } + + ngOnDestroy(): void { + this.wsService.disconnect(); + } + + stateFromCombined(combined: Observable): Observable{ + return combined.pipe(map((c) => c.state)) } public switchTask(idx: number) { diff --git a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts index 2e1328b6..1b5cb600 100644 --- a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts +++ b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts @@ -5,6 +5,8 @@ import { AppConfig } from '../../app.config'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatDialog } from '@angular/material/dialog'; import { catchError, filter, map, shareReplay, switchMap, take } from 'rxjs/operators'; +import { WebSocketService } from '../../services/websocket.service'; +import { ServerMessageType } from '../../model/ws/server-message-type.enum'; import { RunInfoOverviewTuple } from '../admin-run-list.component'; import { MatAccordion } from '@angular/material/expansion'; import { @@ -51,9 +53,22 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { private scoreService: EvaluationScoresService, private downloadService: DownloadService, private snackBar: MatSnackBar, - private dialog: MatDialog + private dialog: MatDialog, + private wsService: WebSocketService ) { this.activeRoute.params.pipe(map((a) => a.runId)).subscribe(this.runId); + + const wsRefresh$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, + ].includes(msg.type)) + ); + this.run = this.runId.pipe( switchMap((runId) => combineLatest([ @@ -70,8 +85,8 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { }), filter((q) => q != null) ), - merge(timer(0, 1000), this.update).pipe( - switchMap((index) => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + merge(timer(0, 30_000), this.update, wsRefresh$).pipe( + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) ), ]) ), @@ -88,7 +103,7 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { shareReplay({ bufferSize: 1, refCount: true }) /* Cache last successful loading. */ ); - this.taskSubmissionCounts = merge(timer(0, 15000), this.update).pipe( + this.taskSubmissionCounts = merge(timer(0, 30_000), this.update, wsRefresh$).pipe( switchMap(() => this.run.pipe(take(1))), switchMap((run) => { const runId = this.runId.getValue(); @@ -123,6 +138,8 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { } ngAfterViewInit(): void { + this.runId.pipe(take(1)).subscribe((id) => this.wsService.connect(id)); + /* Cache past tasks initially */ this.runId.subscribe((runId) => { this.runAdminService @@ -157,6 +174,7 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { } ngOnDestroy(): void { + this.wsService.disconnect(); this.update?.unsubscribe(); } } From 0d9295de837ff0762d93d0468326d042521e69e9 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Sun, 14 Jun 2026 15:46:02 +0100 Subject: [PATCH 07/16] Finishing touchs and bug fixs to pass tests --- .../submissions-list.component.spec.ts | 127 ++++++++++++++ .../judgement-viewer.component.spec.ts | 5 + .../judgement-voting-viewer.component.spec.ts | 116 +++++++++++++ .../app/run/run-admin-view.component.spec.ts | 153 +++++++++++++++++ .../run-async-admin-view.component.spec.ts | 159 ++++++++++++++++++ 5 files changed, 560 insertions(+) create mode 100644 frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.spec.ts create mode 100644 frontend/src/app/judgement/judgement-voting-viewer.component.spec.ts create mode 100644 frontend/src/app/run/run-admin-view.component.spec.ts create mode 100644 frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts diff --git a/frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.spec.ts b/frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.spec.ts new file mode 100644 index 00000000..fab422b0 --- /dev/null +++ b/frontend/src/app/evaluation/admin/submission/submissions-list/submissions-list.component.spec.ts @@ -0,0 +1,127 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { of, Subject } from 'rxjs'; + +import { SubmissionsListComponent } from './submissions-list.component'; +import { AppConfig } from '../../../../app.config'; +import { WebSocketService } from '../../../../services/websocket.service'; +import { EvaluationAdministratorService, EvaluationService, TemplateService } from '../../../../../../openapi'; +import { IWsServerMessage } from '../../../../model/ws/ws-server-message.interface'; +import { ServerMessageType } from '../../../../model/ws/server-message-type.enum'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function wsMsg(type: ServerMessageType.ServerMessageTypeEnum): IWsServerMessage { + return { evaluationId: 'eval-1', type, timestamp: Date.now() }; +} + +const REFRESH_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_END, +]; + +const IRRELEVANT_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.PING, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.TASK_START, +]; + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('SubmissionsListComponent WebSocket wiring', () => { + let component: SubmissionsListComponent; + let fixture: ComponentFixture; + let wsService: jasmine.SpyObj; + let evalService: jasmine.SpyObj; + let evaluationAdminService: jasmine.SpyObj; + let templateService: jasmine.SpyObj; + let messages$: Subject; + + beforeEach(() => { + messages$ = new Subject(); + + wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { + messages$: messages$.asObservable(), + }); + + evalService = jasmine.createSpyObj('EvaluationService', ['getApiV2EvaluationByEvaluationIdInfo']); + evalService.getApiV2EvaluationByEvaluationIdInfo.and.returnValue(of({ id: 'eval-1', templateId: 'tpl-1' } as any)); + + evaluationAdminService = jasmine.createSpyObj('EvaluationAdministratorService', [ + 'getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId', + ]); + evaluationAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId.and.returnValue(of([] as any)); + + templateService = jasmine.createSpyObj('TemplateService', ['getApiV2TemplateByTemplateIdTaskList']); + templateService.getApiV2TemplateByTemplateIdTaskList.and.returnValue(of([{ id: 'task-1' }] as any)); + + TestBed.configureTestingModule({ + declarations: [SubmissionsListComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: EvaluationService, useValue: evalService }, + { provide: EvaluationAdministratorService, useValue: evaluationAdminService }, + { provide: TemplateService, useValue: templateService }, + { + provide: ActivatedRoute, + useValue: { paramMap: of({ get: (key: string) => (key === 'runId' ? 'eval-1' : 'task-1') }) }, + }, + { provide: AppConfig, useValue: { resolveApiUrl: (p: string) => `http://localhost${p}` } }, + { provide: MatSnackBar, useValue: jasmine.createSpyObj('MatSnackBar', ['open']) }, + { provide: MatDialog, useValue: {} }, + { provide: WebSocketService, useValue: wsService }, + ], + }); + + fixture = TestBed.createComponent(SubmissionsListComponent); + component = fixture.componentInstance; + }); + + // ── connect / disconnect ─────────────────────────────────────────────────── + + it('connects the WebSocket with the evaluationId on init', () => { + component.ngAfterViewInit(); + expect(wsService.connect).toHaveBeenCalledWith('eval-1'); + }); + + it('disconnects the WebSocket on destroy', () => { + component.ngAfterViewInit(); + fixture.destroy(); + expect(wsService.disconnect).toHaveBeenCalled(); + }); + + // ── submission list refresh on WS message ───────────────────────────────── + + REFRESH_TYPES.forEach((type) => { + it(`refreshes the submission list when ${type} message is received`, fakeAsync(() => { + component.ngAfterViewInit(); + tick(); + evaluationAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(evaluationAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId).toHaveBeenCalledWith('eval-1', 'task-1'); + discardPeriodicTasks(); + })); + }); + + // ── PING / unrelated messages are ignored ────────────────────────────────── + + IRRELEVANT_TYPES.forEach((type) => { + it(`does not refresh the submission list for ${type} message`, fakeAsync(() => { + component.ngAfterViewInit(); + tick(); + evaluationAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(evaluationAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId).not.toHaveBeenCalled(); + discardPeriodicTasks(); + })); + }); +}); diff --git a/frontend/src/app/judgement/judgement-viewer.component.spec.ts b/frontend/src/app/judgement/judgement-viewer.component.spec.ts index 8b6327a2..4e0f8ff8 100644 --- a/frontend/src/app/judgement/judgement-viewer.component.spec.ts +++ b/frontend/src/app/judgement/judgement-viewer.component.spec.ts @@ -7,6 +7,7 @@ import { of, Subject } from 'rxjs'; import { JudgementViewerComponent } from './judgement-viewer.component'; import { JudgementService } from '../../../openapi'; import { ApiJudgementRequest } from '../../../openapi'; +import { WebSocketService } from '../services/websocket.service'; function makeRequest(token: string, desc = 'Find a cat'): ApiJudgementRequest { return { token, taskDescription: desc, validator: 'v1', answerSet: { answers: [] } } as any; @@ -38,6 +39,10 @@ describe('JudgementViewerComponent', () => { { provide: MatSnackBar, useValue: jasmine.createSpyObj('MatSnackBar', ['open']) }, { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, { provide: MatDialog, useValue: mockDialog }, + { + provide: WebSocketService, + useValue: jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { messages$: new Subject().asObservable() }), + }, ], }); diff --git a/frontend/src/app/judgement/judgement-voting-viewer.component.spec.ts b/frontend/src/app/judgement/judgement-voting-viewer.component.spec.ts new file mode 100644 index 00000000..e9643544 --- /dev/null +++ b/frontend/src/app/judgement/judgement-voting-viewer.component.spec.ts @@ -0,0 +1,116 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { of, Subject } from 'rxjs'; + +import { JudgementVotingViewerComponent } from './judgement-voting-viewer.component'; +import { AppConfig } from '../app.config'; +import { WebSocketService } from '../services/websocket.service'; +import { JudgementService } from '../../../openapi'; +import { IWsServerMessage } from '../model/ws/ws-server-message.interface'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function wsMsg(type: ServerMessageType.ServerMessageTypeEnum): IWsServerMessage { + return { evaluationId: 'eval-1', type, timestamp: Date.now() }; +} + +const REFRESH_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, +]; + +const IRRELEVANT_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.PING, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, +]; + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('JudgementVotingViewerComponent WebSocket wiring', () => { + let component: JudgementVotingViewerComponent; + let wsService: jasmine.SpyObj; + let judgementService: jasmine.SpyObj; + let messages$: Subject; + let fixture: ComponentFixture; + + beforeEach(() => { + messages$ = new Subject(); + + wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { + messages$: messages$.asObservable(), + }); + + judgementService = jasmine.createSpyObj('JudgementService', ['getApiV2EvaluationByEvaluationIdVoteNext']); + judgementService.getApiV2EvaluationByEvaluationIdVoteNext.and.returnValue(of({ status: 202, body: null } as any)); + + TestBed.configureTestingModule({ + declarations: [JudgementVotingViewerComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: JudgementService, useValue: judgementService }, + { provide: ActivatedRoute, useValue: { params: of({ runId: 'eval-1' }) } }, + { provide: AppConfig, useValue: { resolveUrl: (p: string) => `http://localhost/${p}` } }, + { provide: MatSnackBar, useValue: jasmine.createSpyObj('MatSnackBar', ['open']) }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, + { provide: WebSocketService, useValue: wsService }, + ], + }); + + fixture = TestBed.createComponent(JudgementVotingViewerComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + (component as any).requestSub?.unsubscribe(); + }); + + // ── connect / disconnect ─────────────────────────────────────────────────── + + it('connects the WebSocket with the evaluationId on init', () => { + component.ngOnInit(); + expect(wsService.connect).toHaveBeenCalledWith('eval-1'); + }); + + it('disconnects the WebSocket on destroy', () => { + component.ngOnInit(); + fixture.destroy(); + expect(wsService.disconnect).toHaveBeenCalled(); + }); + + // ── vote request refresh on WS message ───────────────────────────────────── + + REFRESH_TYPES.forEach((type) => { + it(`fetches the next vote request when ${type} message is received`, fakeAsync(() => { + component.ngOnInit(); + judgementService.getApiV2EvaluationByEvaluationIdVoteNext.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(judgementService.getApiV2EvaluationByEvaluationIdVoteNext.calls.mostRecent().args).toEqual(['eval-1', 'response']); + + discardPeriodicTasks(); + })); + }); + + // ── PING / unrelated messages are ignored ────────────────────────────────── + + IRRELEVANT_TYPES.forEach((type) => { + it(`does not fetch the next vote request for ${type} message`, fakeAsync(() => { + component.ngOnInit(); + tick(); // consume the initial fallback-poll emission + judgementService.getApiV2EvaluationByEvaluationIdVoteNext.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(judgementService.getApiV2EvaluationByEvaluationIdVoteNext).not.toHaveBeenCalled(); + + discardPeriodicTasks(); + })); + }); +}); diff --git a/frontend/src/app/run/run-admin-view.component.spec.ts b/frontend/src/app/run/run-admin-view.component.spec.ts new file mode 100644 index 00000000..e888e372 --- /dev/null +++ b/frontend/src/app/run/run-admin-view.component.spec.ts @@ -0,0 +1,153 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { of, Subject } from 'rxjs'; + +import { RunAdminViewComponent } from './run-admin-view.component'; +import { AppConfig } from '../app.config'; +import { WebSocketService } from '../services/websocket.service'; +import { EvaluationAdministratorService, EvaluationService, TemplateService } from '../../../openapi'; +import { IWsServerMessage } from '../model/ws/ws-server-message.interface'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function wsMsg(type: ServerMessageType.ServerMessageTypeEnum): IWsServerMessage { + return { evaluationId: 'eval-1', type, timestamp: Date.now() }; +} + +const REFRESH_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, +]; + +const IRRELEVANT_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.PING, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_START, +]; + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('RunAdminViewComponent WebSocket wiring', () => { + let component: RunAdminViewComponent; + let fixture: ComponentFixture; + let wsService: jasmine.SpyObj; + let runService: jasmine.SpyObj; + let runAdminService: jasmine.SpyObj; + let templateService: jasmine.SpyObj; + let messages$: Subject; + + beforeEach(() => { + messages$ = new Subject(); + + wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { + messages$: messages$.asObservable(), + }); + + runService = jasmine.createSpyObj('EvaluationService', [ + 'getApiV2EvaluationByEvaluationIdInfo', + 'getApiV2EvaluationByEvaluationIdState', + ]); + runService.getApiV2EvaluationByEvaluationIdInfo.and.returnValue(of({ id: 'eval-1', templateId: 'tpl-1', name: 'Test' } as any)); + runService.getApiV2EvaluationByEvaluationIdState.and.returnValue(of({ taskTemplateId: 't1', taskStatus: 'RUNNING' } as any)); + + runAdminService = jasmine.createSpyObj('EvaluationAdministratorService', [ + 'getApiV2EvaluationAdminByEvaluationIdOverview', + 'getApiV2EvaluationAdminByEvaluationIdViewerList', + 'getApiV2EvaluationAdminByEvaluationIdTaskPastList', + 'getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId', + ]); + runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview.and.returnValue(of({} as any)); + runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList.and.returnValue(of([] as any)); + runAdminService.getApiV2EvaluationAdminByEvaluationIdTaskPastList.and.returnValue(of([] as any)); + runAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId.and.returnValue(of([] as any)); + + templateService = jasmine.createSpyObj('TemplateService', ['getApiV2TemplateByTemplateIdTeamList']); + templateService.getApiV2TemplateByTemplateIdTeamList.and.returnValue(of([] as any)); + + TestBed.configureTestingModule({ + declarations: [RunAdminViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: EvaluationService, useValue: runService }, + { provide: EvaluationAdministratorService, useValue: runAdminService }, + { provide: TemplateService, useValue: templateService }, + { provide: ActivatedRoute, useValue: { params: of({ runId: 'eval-1' }) } }, + { provide: AppConfig, useValue: { resolveApiUrl: (p: string) => `http://localhost${p}` } }, + { provide: MatSnackBar, useValue: jasmine.createSpyObj('MatSnackBar', ['open']) }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate', 'navigateByUrl']) }, + { provide: MatDialog, useValue: {} }, + { provide: WebSocketService, useValue: wsService }, + ], + }); + + fixture = TestBed.createComponent(RunAdminViewComponent); + component = fixture.componentInstance; + }); + + // ── connect / disconnect ─────────────────────────────────────────────────── + + it('connects the WebSocket with the evaluationId on init', () => { + component.ngOnInit(); + expect(wsService.connect).toHaveBeenCalledWith('eval-1'); + }); + + it('disconnects the WebSocket on destroy', () => { + component.ngOnInit(); + fixture.destroy(); + expect(wsService.disconnect).toHaveBeenCalled(); + }); + + // ── state refresh on WS message ──────────────────────────────────────────── + + REFRESH_TYPES.forEach((type) => { + it(`refreshes run state when ${type} message is received`, fakeAsync(() => { + component.ngOnInit(); + component.run.subscribe(); + tick(); + runService.getApiV2EvaluationByEvaluationIdState.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runService.getApiV2EvaluationByEvaluationIdState).toHaveBeenCalledWith('eval-1'); + discardPeriodicTasks(); + })); + + it(`refreshes the viewer list when ${type} message is received`, fakeAsync(() => { + component.ngOnInit(); + component.viewers.subscribe(); + tick(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList).toHaveBeenCalledWith('eval-1'); + discardPeriodicTasks(); + })); + }); + + // ── PING / unrelated messages are ignored ────────────────────────────────── + + IRRELEVANT_TYPES.forEach((type) => { + it(`does not refresh run state for ${type} message`, fakeAsync(() => { + component.ngOnInit(); + component.run.subscribe(); + tick(); + runService.getApiV2EvaluationByEvaluationIdState.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runService.getApiV2EvaluationByEvaluationIdState).not.toHaveBeenCalled(); + discardPeriodicTasks(); + })); + }); +}); diff --git a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts new file mode 100644 index 00000000..07b346a8 --- /dev/null +++ b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts @@ -0,0 +1,159 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable, of, Subject } from 'rxjs'; + +import { RunAsyncAdminViewComponent } from './run-async-admin-view.component'; +import { AppConfig } from '../../app.config'; +import { WebSocketService } from '../../services/websocket.service'; +import { + DownloadService, + EvaluationAdministratorService, + EvaluationClientService, + EvaluationScoresService, + EvaluationService, +} from '../../../../openapi'; +import { IWsServerMessage } from '../../model/ws/ws-server-message.interface'; +import { ServerMessageType } from '../../model/ws/server-message-type.enum'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function wsMsg(type: ServerMessageType.ServerMessageTypeEnum): IWsServerMessage { + return { evaluationId: 'eval-1', type, timestamp: Date.now() }; +} + +const REFRESH_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, +]; + +const IRRELEVANT_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.PING, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_START, +]; + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe('RunAsyncAdminViewComponent WebSocket wiring', () => { + let component: RunAsyncAdminViewComponent; + let fixture: ComponentFixture; + let wsService: jasmine.SpyObj; + let evaluationService: jasmine.SpyObj; + let runAdminService: jasmine.SpyObj; + let messages$: Subject; + + beforeEach(() => { + messages$ = new Subject(); + + wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { + messages$: messages$.asObservable(), + }); + + evaluationService = jasmine.createSpyObj('EvaluationService', ['getApiV2EvaluationByEvaluationIdInfo']); + evaluationService.getApiV2EvaluationByEvaluationIdInfo.and.returnValue( + of({ id: 'eval-1', templateId: 'tpl-1', name: 'Test', teams: [], taskTemplates: [{ templateId: 't1' }] } as any) + ); + + runAdminService = jasmine.createSpyObj('EvaluationAdministratorService', [ + 'getApiV2EvaluationAdminByEvaluationIdOverview', + 'getApiV2EvaluationAdminByEvaluationIdTaskPastList', + 'getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId', + ]); + runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview.and.returnValue(of({} as any)); + runAdminService.getApiV2EvaluationAdminByEvaluationIdTaskPastList.and.returnValue(of([] as any)); + runAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId.and.returnValue(of([] as any)); + + TestBed.configureTestingModule({ + declarations: [RunAsyncAdminViewComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: EvaluationService, useValue: evaluationService }, + { provide: EvaluationAdministratorService, useValue: runAdminService }, + { provide: EvaluationClientService, useValue: {} }, + { provide: EvaluationScoresService, useValue: {} }, + { provide: DownloadService, useValue: {} }, + { + provide: ActivatedRoute, + // Use a non-completing Observable: the component subscribes its `runId` + // BehaviorSubject directly to `params`, so a source like `of(...)` that + // completes synchronously would also complete (close) that subject. + useValue: { params: new Observable((subscriber) => subscriber.next({ runId: 'eval-1' })) }, + }, + { provide: AppConfig, useValue: { resolveApiUrl: (p: string) => `http://localhost${p}` } }, + { provide: MatSnackBar, useValue: jasmine.createSpyObj('MatSnackBar', ['open']) }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate', 'navigateByUrl']) }, + { provide: MatDialog, useValue: {} }, + { provide: WebSocketService, useValue: wsService }, + ], + }); + + fixture = TestBed.createComponent(RunAsyncAdminViewComponent); + component = fixture.componentInstance; + }); + + // ── connect / disconnect ─────────────────────────────────────────────────── + + it('connects the WebSocket with the evaluationId on init', () => { + component.ngAfterViewInit(); + expect(wsService.connect).toHaveBeenCalledWith('eval-1'); + }); + + it('disconnects the WebSocket on destroy', () => { + component.ngAfterViewInit(); + fixture.destroy(); + expect(wsService.disconnect).toHaveBeenCalled(); + }); + + // ── refresh on WS message ────────────────────────────────────────────────── + + REFRESH_TYPES.forEach((type) => { + it(`refreshes the run overview when ${type} message is received`, fakeAsync(() => { + component.ngAfterViewInit(); + component.run.subscribe(); + tick(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview).toHaveBeenCalledWith('eval-1'); + discardPeriodicTasks(); + })); + + it(`refreshes the task submission counts when ${type} message is received`, fakeAsync(() => { + component.ngAfterViewInit(); + component.taskSubmissionCounts.subscribe(); + tick(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId).toHaveBeenCalledWith('eval-1', 't1'); + discardPeriodicTasks(); + })); + }); + + // ── PING / unrelated messages are ignored ────────────────────────────────── + + IRRELEVANT_TYPES.forEach((type) => { + it(`does not refresh the run overview for ${type} message`, fakeAsync(() => { + component.ngAfterViewInit(); + component.run.subscribe(); + tick(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview).not.toHaveBeenCalled(); + discardPeriodicTasks(); + })); + }); +}); From 52742332710ff7b86ab37313c894efc44570228f Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Sun, 14 Jun 2026 19:57:33 +0100 Subject: [PATCH 08/16] Tests for judgement viewer aswell --- .../judgement-viewer.component.spec.ts | 123 +++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/judgement/judgement-viewer.component.spec.ts b/frontend/src/app/judgement/judgement-viewer.component.spec.ts index 4e0f8ff8..55e953bf 100644 --- a/frontend/src/app/judgement/judgement-viewer.component.spec.ts +++ b/frontend/src/app/judgement/judgement-viewer.component.spec.ts @@ -1,5 +1,5 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; @@ -8,11 +8,28 @@ import { JudgementViewerComponent } from './judgement-viewer.component'; import { JudgementService } from '../../../openapi'; import { ApiJudgementRequest } from '../../../openapi'; import { WebSocketService } from '../services/websocket.service'; +import { IWsServerMessage } from '../model/ws/ws-server-message.interface'; +import { ServerMessageType } from '../model/ws/server-message-type.enum'; function makeRequest(token: string, desc = 'Find a cat'): ApiJudgementRequest { return { token, taskDescription: desc, validator: 'v1', answerSet: { answers: [] } } as any; } +function wsMsg(type: ServerMessageType.ServerMessageTypeEnum): IWsServerMessage { + return { evaluationId: 'run1', type, timestamp: Date.now() }; +} + +const REFRESH_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, +]; + +const IRRELEVANT_TYPES: ServerMessageType.ServerMessageTypeEnum[] = [ + ServerMessageType.ServerMessageTypeEnum.PING, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, +]; + describe('JudgementViewerComponent', () => { let component: JudgementViewerComponent; let mockDialog: jasmine.SpyObj; @@ -135,3 +152,107 @@ describe('JudgementViewerComponent', () => { }); }); }); + +// ── WebSocket wiring ──────────────────────────────────────────────────────── + +describe('JudgementViewerComponent WebSocket wiring', () => { + let component: JudgementViewerComponent; + let fixture: ComponentFixture; + let wsService: jasmine.SpyObj; + let judgementService: jasmine.SpyObj; + let messages$: Subject; + let dialogAfterClosed: Subject; + + beforeEach(() => { + messages$ = new Subject(); + dialogAfterClosed = new Subject(); + + wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { + messages$: messages$.asObservable(), + }); + + judgementService = jasmine.createSpyObj('JudgementService', [ + 'postApiV2EvaluationByEvaluationIdJudge', + 'getApiV2EvaluationByEvaluationIdJudgeNext', + 'getApiV2EvaluationByEvaluationIdJudgeStatus', + ]); + judgementService.getApiV2EvaluationByEvaluationIdJudgeStatus.and.returnValue(of([] as any)); + judgementService.getApiV2EvaluationByEvaluationIdJudgeNext.and.returnValue(of({ status: 202, body: null } as any)); + + const fakeDialogRef = { afterClosed: () => dialogAfterClosed.asObservable() } as MatDialogRef; + const mockDialog = jasmine.createSpyObj('MatDialog', ['open']); + mockDialog.open.and.returnValue(fakeDialogRef); + + TestBed.configureTestingModule({ + declarations: [JudgementViewerComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: JudgementService, useValue: judgementService }, + { provide: ActivatedRoute, useValue: { params: of({ runId: 'run1' }) } }, + { provide: MatSnackBar, useValue: jasmine.createSpyObj('MatSnackBar', ['open']) }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, + { provide: MatDialog, useValue: mockDialog }, + { provide: WebSocketService, useValue: wsService }, + ], + }); + + fixture = TestBed.createComponent(JudgementViewerComponent); + component = fixture.componentInstance; + component.judgePlayer = jasmine.createSpyObj('JudgementMediaViewerComponent', ['stop', 'togglePlaying']); + }); + + // ── connect / disconnect ─────────────────────────────────────────────────── + + it('connects the WebSocket with the evaluationId on init', () => { + component.ngAfterViewInit(); + expect(wsService.connect).toHaveBeenCalledWith('run1'); + }); + + it('disconnects the WebSocket on destroy', () => { + component.ngAfterViewInit(); + fixture.destroy(); + expect(wsService.disconnect).toHaveBeenCalled(); + }); + + // ── judgement request / status refresh on WS message ─────────────────────── + + REFRESH_TYPES.forEach((type) => { + it(`fetches the next judgement request and status when ${type} message is received`, fakeAsync(() => { + component.ngAfterViewInit(); + dialogAfterClosed.next(); + tick(); + + judgementService.getApiV2EvaluationByEvaluationIdJudgeNext.calls.reset(); + judgementService.getApiV2EvaluationByEvaluationIdJudgeStatus.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(judgementService.getApiV2EvaluationByEvaluationIdJudgeNext.calls.mostRecent().args).toEqual(['run1', 'response']); + expect(judgementService.getApiV2EvaluationByEvaluationIdJudgeStatus).toHaveBeenCalledWith('run1'); + + discardPeriodicTasks(); + })); + }); + + // ── PING / unrelated messages are ignored ────────────────────────────────── + + IRRELEVANT_TYPES.forEach((type) => { + it(`does not fetch the next judgement request for ${type} message`, fakeAsync(() => { + component.ngAfterViewInit(); + dialogAfterClosed.next(); + tick(); + + judgementService.getApiV2EvaluationByEvaluationIdJudgeNext.calls.reset(); + judgementService.getApiV2EvaluationByEvaluationIdJudgeStatus.calls.reset(); + + messages$.next(wsMsg(type)); + tick(); + + expect(judgementService.getApiV2EvaluationByEvaluationIdJudgeNext).not.toHaveBeenCalled(); + expect(judgementService.getApiV2EvaluationByEvaluationIdJudgeStatus).not.toHaveBeenCalled(); + + discardPeriodicTasks(); + })); + }); +}); From 96015009643c5dfede0e75048218f125f66ea768 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Sun, 14 Jun 2026 21:16:22 +0100 Subject: [PATCH 09/16] Fixed a self deadlock issue --- .../main/kotlin/dev/dres/run/RunExecutor.kt | 61 +++++++------------ 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt index 27123259..ec0f9cbc 100644 --- a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt +++ b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt @@ -47,10 +47,7 @@ object RunExecutor : StreamEventHandler { private val connectedClients = ConcurrentHashMap() /** Session IDs currently observing each evaluation. */ - private val observingClients = HashMap>() - - /** Lock for WebSocket client data structures. */ - private val clientLock = ReentrantReadWriteLock() + private val observingClients = ConcurrentHashMap>() /** Lock for accessing and changing all data structures related to [RunManager]s. */ private val runManagerLock = ReentrantReadWriteLock() @@ -95,26 +92,26 @@ object RunExecutor : StreamEventHandler { throw IllegalArgumentException("This RunExecutor already runs a RunManager with the given ID ${manager.id}. The same RunManager cannot be executed twice!") } this.runManagers[manager.id] = manager - this.observingClients[manager.id] = HashSet() + this.observingClients[manager.id] = ConcurrentHashMap.newKeySet() this.results[this.executor.submit(manager)] = manager.id } /** A thread that cleans after [RunManager]s have finished. */ private val cleanerThread = Thread { while (!this@RunExecutor.executor.isShutdown) { - this@RunExecutor.runManagerLock.read { - this@RunExecutor.results.entries.removeIf { entry -> - val k = entry.key - val v = entry.value - if (k.isDone || k.isCancelled) { + /* Determine which futures have finished without holding the write lock. */ + val finished = this@RunExecutor.runManagerLock.read { + this@RunExecutor.results.entries.filter { (k, _) -> k.isDone || k.isCancelled } + } + if (finished.isNotEmpty()) { + /* Acquiring the write lock separately (rather than upgrading from the read lock above, + * which ReentrantReadWriteLock does not support and would deadlock). */ + this@RunExecutor.runManagerLock.write { + finished.forEach { (k, v) -> logger.info("RunManager $v (done = ${k.isDone}, cancelled = ${k.isCancelled}) will be removed!") - this@RunExecutor.runManagerLock.write { - this@RunExecutor.runManagers.remove(v) - this@RunExecutor.observingClients.remove(v) - } - true - } else { - false + this@RunExecutor.results.remove(k) + this@RunExecutor.runManagers.remove(v) + this@RunExecutor.observingClients.remove(v) } } } @@ -136,19 +133,13 @@ object RunExecutor : StreamEventHandler { */ fun accept(ws: WsConfig) { ws.onConnect { ctx -> - this.clientLock.write { - this.connectedClients[ctx.sessionId()] = ctx - } + this.connectedClients[ctx.sessionId()] = ctx logger.debug("WebSocket client connected: ${ctx.sessionId()}") } ws.onClose { ctx -> - this.clientLock.write { - this.connectedClients.remove(ctx.sessionId()) - this.runManagerLock.read { - this.observingClients.values.forEach { it.remove(ctx.sessionId()) } - } - } + this.connectedClients.remove(ctx.sessionId()) + this.observingClients.values.forEach { it.remove(ctx.sessionId()) } logger.debug("WebSocket client disconnected: ${ctx.sessionId()}") } @@ -164,12 +155,8 @@ object RunExecutor : StreamEventHandler { if (this.runManagers.containsKey(message.evaluationId)) { when (message.type) { ClientMessageType.ACK -> {} - ClientMessageType.REGISTER -> this.clientLock.write { - this.observingClients[message.evaluationId]?.add(ctx.sessionId()) - } - ClientMessageType.UNREGISTER -> this.clientLock.write { - this.observingClients[message.evaluationId]?.remove(ctx.sessionId()) - } + ClientMessageType.REGISTER -> this.observingClients[message.evaluationId]?.add(ctx.sessionId()) + ClientMessageType.UNREGISTER -> this.observingClients[message.evaluationId]?.remove(ctx.sessionId()) ClientMessageType.PING -> ctx.send( mapper.writeValueAsString(ServerMessage(message.evaluationId, ServerMessageType.PING)) ) @@ -180,9 +167,7 @@ object RunExecutor : StreamEventHandler { ws.onError { ctx -> logger.error("WebSocket error for session ${ctx.sessionId()}: ${ctx.error()?.message}") - this.clientLock.write { - this.connectedClients.remove(ctx.sessionId()) - } + this.connectedClients.remove(ctx.sessionId()) } } @@ -198,11 +183,7 @@ object RunExecutor : StreamEventHandler { logger.error("Failed to serialize ServerMessage: ${e.message}") return } - val observers = this.clientLock.read { - this.runManagerLock.read { - this.observingClients[message.evaluationId]?.toSet() ?: emptySet() - } - } + val observers = this.observingClients[message.evaluationId]?.toSet() ?: emptySet() observers.forEach { sessionId -> try { this.connectedClients[sessionId]?.send(json) From a20186c4e4595c3175414eec6f1f5844bf3a7853 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Sun, 14 Jun 2026 21:46:18 +0100 Subject: [PATCH 10/16] Fixing some websocket auth issues --- .../kotlin/dev/dres/api/rest/AccessManager.kt | 17 ++++++++++++++ .../main/kotlin/dev/dres/run/RunExecutor.kt | 13 +++++++++-- .../utilities/extensions/ContextExtensions.kt | 7 ++++++ .../src/app/services/websocket.service.ts | 22 ++++++++++++++++++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/backend/src/main/kotlin/dev/dres/api/rest/AccessManager.kt b/backend/src/main/kotlin/dev/dres/api/rest/AccessManager.kt index 784fac71..8b737474 100644 --- a/backend/src/main/kotlin/dev/dres/api/rest/AccessManager.kt +++ b/backend/src/main/kotlin/dev/dres/api/rest/AccessManager.kt @@ -165,4 +165,21 @@ object AccessManager { fun getRunManagerForUser(userId: UserId): Set = this.locks.read { return this.usersToRunMap[userId] ?: emptySet() } + + /** + * Checks whether the user associated with the given [SessionToken] is allowed to observe + * (e.g., via WebSocket) the given [runManager], mirroring the access rules applied to the REST API. + * + * @param sessionId The [SessionToken] to check. + * @param runManager The [RunManager] the session wants to observe. + * @return True if access is permitted, false otherwise. + */ + fun canViewEvaluation(sessionId: SessionToken?, runManager: RunManager): Boolean { + val roles = rolesOfSession(sessionId) + if (roles.contains(ApiRole.ADMIN)) return true + val userId = userIdForSession(sessionId) ?: return false + return (roles.contains(ApiRole.JUDGE) && runManager.template.judges.contains(userId)) || + (roles.contains(ApiRole.VIEWER) && runManager.template.viewers.contains(userId)) || + (roles.contains(ApiRole.PARTICIPANT) && runManager.template.hasParticipant(userId)) + } } diff --git a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt index ec0f9cbc..0222a802 100644 --- a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt +++ b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt @@ -1,6 +1,7 @@ package dev.dres.run import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.dres.api.rest.AccessManager import dev.dres.api.rest.types.ViewerInfo import dev.dres.api.rest.types.evaluation.websocket.ClientMessage import dev.dres.api.rest.types.evaluation.websocket.ClientMessageType @@ -10,6 +11,7 @@ import dev.dres.data.model.run.* import dev.dres.data.model.run.interfaces.EvaluationId import dev.dres.run.eventstream.* import dev.dres.run.validation.interfaces.JudgementValidator +import dev.dres.utilities.extensions.sessionToken import io.javalin.websocket.WsConfig import io.javalin.websocket.WsContext import jetbrains.exodus.database.TransientEntityStore @@ -152,10 +154,17 @@ object RunExecutor : StreamEventHandler { } logger.debug("Received WebSocket message: $message from ${ctx.sessionId()}") this.runManagerLock.read { - if (this.runManagers.containsKey(message.evaluationId)) { + val manager = this.runManagers[message.evaluationId] + if (manager != null) { when (message.type) { ClientMessageType.ACK -> {} - ClientMessageType.REGISTER -> this.observingClients[message.evaluationId]?.add(ctx.sessionId()) + ClientMessageType.REGISTER -> { + if (AccessManager.canViewEvaluation(ctx.sessionToken(), manager)) { + this.observingClients[message.evaluationId]?.add(ctx.sessionId()) + } else { + logger.warn("WebSocket session ${ctx.sessionId()} is not permitted to observe evaluation ${message.evaluationId}") + } + } ClientMessageType.UNREGISTER -> this.observingClients[message.evaluationId]?.remove(ctx.sessionId()) ClientMessageType.PING -> ctx.send( mapper.writeValueAsString(ServerMessage(message.evaluationId, ServerMessageType.PING)) diff --git a/backend/src/main/kotlin/dev/dres/utilities/extensions/ContextExtensions.kt b/backend/src/main/kotlin/dev/dres/utilities/extensions/ContextExtensions.kt index 67650ea3..9297435b 100644 --- a/backend/src/main/kotlin/dev/dres/utilities/extensions/ContextExtensions.kt +++ b/backend/src/main/kotlin/dev/dres/utilities/extensions/ContextExtensions.kt @@ -11,6 +11,7 @@ import dev.dres.run.RunExecutor import dev.dres.run.RunManager import dev.dres.run.RunManagerStatus import io.javalin.http.Context +import io.javalin.websocket.WsContext import kotlinx.dnq.query.filter import kotlinx.dnq.query.flatMapDistinct import kotlinx.dnq.query.isNotEmpty @@ -72,6 +73,12 @@ fun Context.sendFile(file: File) { fun Context.sessionToken(): String? = this.attribute("session") +/** + * Returns the session token for this [WsContext], as set by the `before` handler on the + * underlying HTTP upgrade request. + */ +fun WsContext.sessionToken(): String? = this.attribute("session") + fun Context.getOrCreateSessionToken(): String { val attributeId = this.attribute("session") if (attributeId != null) { diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 0b9798fa..3ae22e25 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -9,10 +9,16 @@ import { ClientMessageType } from '../model/ws/client-message-type.enum'; providedIn: 'root', }) export class WebSocketService implements OnDestroy { + /** Base delay for the first reconnect attempt. */ + private static readonly RECONNECT_BASE_DELAY_MS = 1_000; + /** Upper bound for the reconnect delay, reached after repeated failures. */ + private static readonly RECONNECT_MAX_DELAY_MS = 30_000; + private socket: WebSocket | null = null; private messageSubject = new Subject(); private pingSubscription: Subscription | null = null; private reconnectTimeout: ReturnType | null = null; + private reconnectAttempts = 0; private currentEvaluationId: string | null = null; /** Observable stream of messages pushed by the server. */ @@ -44,6 +50,7 @@ export class WebSocketService implements OnDestroy { this.socket = new WebSocket(url); this.socket.onopen = () => { + this.reconnectAttempts = 0; this.send({ evaluationId: this.currentEvaluationId, type: ClientMessageType.ClientMessageTypeEnum.REGISTER, @@ -63,7 +70,8 @@ export class WebSocketService implements OnDestroy { this.socket.onclose = () => { this.stopPing(); if (this.currentEvaluationId) { - this.reconnectTimeout = setTimeout(() => this.openConnection(), 5000); + const delay = this.nextReconnectDelay(); + this.reconnectTimeout = setTimeout(() => this.openConnection(), delay); } }; @@ -79,6 +87,7 @@ export class WebSocketService implements OnDestroy { } private closeSocket(): void { + this.reconnectAttempts = 0; if (this.reconnectTimeout !== null) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -96,6 +105,17 @@ export class WebSocketService implements OnDestroy { } } + /** + * Computes the delay before the next reconnect attempt using exponential backoff with jitter, + * capped at {@link RECONNECT_MAX_DELAY_MS}. + */ + private nextReconnectDelay(): number { + const exponentialDelay = WebSocketService.RECONNECT_BASE_DELAY_MS * 2 ** this.reconnectAttempts; + const delay = Math.min(exponentialDelay, WebSocketService.RECONNECT_MAX_DELAY_MS); + this.reconnectAttempts++; + return delay / 2 + Math.random() * (delay / 2); + } + private startPing(): void { this.pingSubscription = interval(30_000).subscribe(() => { if (this.currentEvaluationId) { From bdabbf1ebad8b83c42bc37b70e38d3640c3834c4 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Thu, 18 Jun 2026 18:12:47 +0100 Subject: [PATCH 11/16] WS message carries the data insid it and theres a 30s and no state fallback --- .../evaluation/websocket/ServerMessage.kt | 16 +++- .../main/kotlin/dev/dres/run/RunExecutor.kt | 76 +++++++++++++++++-- .../model/ws/ws-server-message.interface.ts | 9 +++ .../src/app/run/run-admin-view.component.ts | 64 ++++++++++++++-- .../run-async-admin-view.component.ts | 25 ++++-- 5 files changed, 170 insertions(+), 20 deletions(-) diff --git a/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt b/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt index b3646e04..24b26d9f 100644 --- a/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt +++ b/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt @@ -1,13 +1,25 @@ package dev.dres.api.rest.types.evaluation.websocket +import dev.dres.api.rest.types.evaluation.ApiEvaluationOverview +import dev.dres.api.rest.types.evaluation.ApiEvaluationState import dev.dres.data.model.run.interfaces.EvaluationId import dev.dres.data.model.run.interfaces.TaskId /** * Message send by the DRES server via WebSocket to inform clients about the state of the run. * + * The optional [state] and [overview] fields carry a state diff so that receivers can update + * their local representation without issuing a separate HTTP request. + * * @author Ralph Gasser - * @version 1.2.0 + * @version 1.3.0 */ -data class ServerMessage(val evaluationId: EvaluationId, val type: ServerMessageType, val taskId: TaskId? = null, val timestamp: Long = System.currentTimeMillis()) +data class ServerMessage( + val evaluationId: EvaluationId, + val type: ServerMessageType, + val taskId: TaskId? = null, + val timestamp: Long = System.currentTimeMillis(), + val state: ApiEvaluationState? = null, + val overview: ApiEvaluationOverview? = null +) diff --git a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt index 0222a802..47f13335 100644 --- a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt +++ b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt @@ -3,6 +3,8 @@ package dev.dres.run import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import dev.dres.api.rest.AccessManager import dev.dres.api.rest.types.ViewerInfo +import dev.dres.api.rest.types.evaluation.ApiEvaluationOverview +import dev.dres.api.rest.types.evaluation.ApiEvaluationState import dev.dres.api.rest.types.evaluation.websocket.ClientMessage import dev.dres.api.rest.types.evaluation.websocket.ClientMessageType import dev.dres.api.rest.types.evaluation.websocket.ServerMessage @@ -202,17 +204,79 @@ object RunExecutor : StreamEventHandler { } } + /** + * Builds an [ApiEvaluationState] for the given [InteractiveRunManager] inside a readonly + * transaction. Returns null on any failure so callers can fall back to an HTTP fetch. + */ + private fun InteractiveRunManager.buildState(): ApiEvaluationState? = runCatching { + this.store.transactional(readonly = true) { + ApiEvaluationState(this@buildState, RunActionContext.INTERNAL) + } + }.onFailure { logger.warn("Failed to build state diff for WS message: ${it.message}") }.getOrNull() + + /** + * Builds an [ApiEvaluationOverview] for the given [InteractiveRunManager] inside a readonly + * transaction. Returns null on any failure so callers can fall back to an HTTP fetch. + */ + private fun InteractiveRunManager.buildOverview(): ApiEvaluationOverview? = runCatching { + this.store.transactional(readonly = true) { + ApiEvaluationOverview.of(this@buildOverview) + } + }.onFailure { logger.warn("Failed to build overview diff for WS message: ${it.message}") }.getOrNull() + /** * Maps a [StreamEvent] to the [ServerMessage] that should be broadcast, or null * if the event type requires no WebSocket notification. + * + * When the event carries enough information, [ServerMessage.state] and/or + * [ServerMessage.overview] are populated so that receivers can update their local + * state without issuing a separate HTTP request. */ internal fun eventToMessage(event: StreamEvent): ServerMessage? = when (event) { - is RunStartEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_START) - is RunEndEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_END) - is TaskStartEvent -> ServerMessage(event.runId, ServerMessageType.TASK_START, event.taskId) - is TaskEndEvent -> ServerMessage(event.runId, ServerMessageType.TASK_END, event.taskId) - is ScoreUpdateEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_UPDATE) - is SubmissionEvent -> ServerMessage(event.runId, ServerMessageType.TASK_UPDATED) + is RunStartEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_START) + + is RunEndEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_END) + + is TaskStartEvent -> { + val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } + ServerMessage( + event.runId, + ServerMessageType.TASK_START, + event.taskId, + state = manager?.buildState(), + overview = manager?.buildOverview() + ) + } + + is TaskEndEvent -> { + val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } + ServerMessage( + event.runId, + ServerMessageType.TASK_END, + event.taskId, + state = manager?.buildState(), + overview = manager?.buildOverview() + ) + } + + is ScoreUpdateEvent -> { + val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } + ServerMessage( + event.runId, + ServerMessageType.COMPETITION_UPDATE, + overview = manager?.buildOverview() + ) + } + + is SubmissionEvent -> { + val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } + ServerMessage( + event.runId, + ServerMessageType.TASK_UPDATED, + overview = manager?.buildOverview() + ) + } + else -> null } diff --git a/frontend/src/app/model/ws/ws-server-message.interface.ts b/frontend/src/app/model/ws/ws-server-message.interface.ts index addcd8cb..8cb0d4cd 100644 --- a/frontend/src/app/model/ws/ws-server-message.interface.ts +++ b/frontend/src/app/model/ws/ws-server-message.interface.ts @@ -1,13 +1,22 @@ import { ServerMessageType } from './server-message-type.enum'; import ServerMessageTypeEnum = ServerMessageType.ServerMessageTypeEnum; import { IWsMessage } from './ws-message.interface'; +import { ApiEvaluationState } from '../../../../openapi/model/apiEvaluationState'; +import { ApiEvaluationOverview } from '../../../../openapi/model/apiEvaluationOverview'; /** * Structure of a ServerMessage as generated by the DRES server. + * + * When state or overview are present, consumers should apply them directly + * to their local state instead of issuing a separate HTTP request. */ export interface IWsServerMessage extends IWsMessage { evaluationId: string; taskId?: string; type: ServerMessageTypeEnum; timestamp: number; + /** Embedded evaluation state diff. Present on TASK_START, TASK_END. */ + state?: ApiEvaluationState; + /** Embedded evaluation overview diff. Present on TASK_START, TASK_END, COMPETITION_UPDATE, TASK_UPDATED. */ + overview?: ApiEvaluationOverview; } diff --git a/frontend/src/app/run/run-admin-view.component.ts b/frontend/src/app/run/run-admin-view.component.ts index 67fb4a09..b7bfc03e 100644 --- a/frontend/src/app/run/run-admin-view.component.ts +++ b/frontend/src/app/run/run-admin-view.component.ts @@ -58,13 +58,31 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { private dialog: MatDialog, private wsService: WebSocketService ) { - const wsRefresh$ = this.wsService.messages$.pipe( + /* WS messages that carry a task-state diff (TASK_START, TASK_END, TASK_PREPARE). */ + const taskStateWs$ = this.wsService.messages$.pipe( filter((msg) => [ ServerMessageType.ServerMessageTypeEnum.TASK_START, ServerMessageType.ServerMessageTypeEnum.TASK_END, - ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, + ].includes(msg.type)) + ); + + /* WS messages that carry an overview diff (submissions, scores, task transitions). */ + const overviewWs$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ].includes(msg.type)) + ); + + /* Any WS event that previously triggered a viewer-list refresh. */ + const viewerWs$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, ].includes(msg.type)) ); @@ -88,7 +106,22 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { }), filter((q) => q != null) ), - merge(timer(0, 30_000), this.refreshSubject, wsRefresh$).pipe(switchMap(() => this.runService.getApiV2EvaluationByEvaluationIdState(runId))), + merge( + /* Safety fallback: full HTTP fetch every 30 s or on manual refresh. */ + merge(timer(0, 30_000), this.refreshSubject).pipe( + switchMap(() => this.runService.getApiV2EvaluationByEvaluationIdState(runId)) + ), + /* Apply diff directly when the WS message carries state — no HTTP needed. */ + taskStateWs$.pipe( + filter((msg) => msg.state != null), + map((msg) => msg.state as ApiEvaluationState) + ), + /* Fallback HTTP for task-state events whose payload is absent. */ + taskStateWs$.pipe( + filter((msg) => msg.state == null), + switchMap(() => this.runService.getApiV2EvaluationByEvaluationIdState(runId)) + ) + ), ]) ), map(([i, s]) => { @@ -118,6 +151,12 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { shareReplay({ bufferSize: 1, refCount: true }) /* Cache last successful loading. */ ); + /* Fires when a task-state event OR a new submission arrives — needed to keep the + submission count current without waiting for the next 30 s poll. */ + const submissionWs$ = this.wsService.messages$.pipe( + filter((msg) => msg.type === ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED) + ); + /** Observable for list of submissions for current task. */ this.submissionsForCurrentTask = this.run.pipe( switchMap((s) => @@ -161,8 +200,21 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { }), filter((q) => q != null) ), - merge(timer(0, 30_000), this.refreshSubject, wsRefresh$).pipe( - switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + merge( + /* Safety fallback: full HTTP fetch every 30 s or on manual refresh. */ + merge(timer(0, 30_000), this.refreshSubject).pipe( + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + ), + /* Apply diff directly when the WS message carries overview — no HTTP needed. */ + overviewWs$.pipe( + filter((msg) => msg.overview != null), + map((msg) => msg.overview as ApiEvaluationOverview) + ), + /* Fallback HTTP for events whose overview payload is absent. */ + overviewWs$.pipe( + filter((msg) => msg.overview == null), + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + ) ), ]) ), @@ -173,7 +225,7 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { ); this.viewers = this.runId.pipe( - mergeMap((runId) => merge(timer(0, 30_000), wsRefresh$).pipe(switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)))) + mergeMap((runId) => merge(timer(0, 30_000), viewerWs$).pipe(switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)))) ); this.teams = this.run.pipe( diff --git a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts index 1b5cb600..90b815e9 100644 --- a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts +++ b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts @@ -10,6 +10,7 @@ import { ServerMessageType } from '../../model/ws/server-message-type.enum'; import { RunInfoOverviewTuple } from '../admin-run-list.component'; import { MatAccordion } from '@angular/material/expansion'; import { + ApiEvaluationOverview, ApiTaskTemplateInfo, ApiTeam, ApiTeamInfo, @@ -58,14 +59,13 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { ) { this.activeRoute.params.pipe(map((a) => a.runId)).subscribe(this.runId); - const wsRefresh$ = this.wsService.messages$.pipe( + /* WS messages that carry an overview diff (submissions, scores, task transitions). */ + const overviewWs$ = this.wsService.messages$.pipe( filter((msg) => [ ServerMessageType.ServerMessageTypeEnum.TASK_START, ServerMessageType.ServerMessageTypeEnum.TASK_END, ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, - ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, - ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, ].includes(msg.type)) ); @@ -85,8 +85,21 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { }), filter((q) => q != null) ), - merge(timer(0, 30_000), this.update, wsRefresh$).pipe( - switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + merge( + /* Safety fallback: full HTTP fetch every 30 s or on manual update trigger. */ + merge(timer(0, 30_000), this.update).pipe( + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + ), + /* Apply diff directly when the WS message carries overview — no HTTP needed. */ + overviewWs$.pipe( + filter((msg) => msg.overview != null), + map((msg) => msg.overview as ApiEvaluationOverview) + ), + /* Fallback HTTP for events whose overview payload is absent. */ + overviewWs$.pipe( + filter((msg) => msg.overview == null), + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + ) ), ]) ), @@ -103,7 +116,7 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { shareReplay({ bufferSize: 1, refCount: true }) /* Cache last successful loading. */ ); - this.taskSubmissionCounts = merge(timer(0, 30_000), this.update, wsRefresh$).pipe( + this.taskSubmissionCounts = merge(timer(0, 30_000), this.update, overviewWs$).pipe( switchMap(() => this.run.pipe(take(1))), switchMap((run) => { const runId = this.runId.getValue(); From 36b41da9612648fded0a30207a10956ea571f18f Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Thu, 18 Jun 2026 18:44:50 +0100 Subject: [PATCH 12/16] Added viewer update server message and merge buildstate and buildoverview into one --- .../evaluation/websocket/ServerMessageType.kt | 1 + .../run/InteractiveAsynchronousRunManager.kt | 4 ++ .../run/InteractiveSynchronousRunManager.kt | 3 ++ .../main/kotlin/dev/dres/run/RunExecutor.kt | 52 ++++++++----------- .../dev/dres/run/eventstream/StreamEvent.kt | 3 +- .../app/model/ws/server-message-type.enum.ts | 3 ++ .../src/app/run/run-admin-view.component.ts | 8 +-- 7 files changed, 36 insertions(+), 38 deletions(-) diff --git a/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessageType.kt b/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessageType.kt index cad6f81d..a9381591 100644 --- a/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessageType.kt +++ b/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessageType.kt @@ -14,5 +14,6 @@ enum class ServerMessageType { TASK_START, /** Task run started. */ TASK_UPDATED, /** State of task run has changed; mostly handles arrival of or changes to submissions. */ TASK_END, /** Tasks run ended. */ + VIEWER_UPDATE, /** A viewer connected or signalled ready — the viewer list has changed. */ PING /** Keep alive */ } \ No newline at end of file diff --git a/backend/src/main/kotlin/dev/dres/run/InteractiveAsynchronousRunManager.kt b/backend/src/main/kotlin/dev/dres/run/InteractiveAsynchronousRunManager.kt index 57bb437c..8af0a195 100644 --- a/backend/src/main/kotlin/dev/dres/run/InteractiveAsynchronousRunManager.kt +++ b/backend/src/main/kotlin/dev/dres/run/InteractiveAsynchronousRunManager.kt @@ -21,6 +21,8 @@ import dev.dres.data.model.template.team.TeamId import dev.dres.run.RunManager.Companion.MAXIMUM_RUN_LOOP_ERROR_COUNT import dev.dres.run.audit.AuditLogSource import dev.dres.run.audit.AuditLogger +import dev.dres.run.eventstream.EventStreamProcessor +import dev.dres.run.eventstream.ViewerUpdateEvent import dev.dres.run.exceptions.IllegalRunStateException import dev.dres.run.exceptions.IllegalTeamIdException import dev.dres.run.score.scoreboard.Scoreboard @@ -622,6 +624,7 @@ class InteractiveAsynchronousRunManager( val currentTaskId = this.currentTask(rac)?.taskId if (taskTemplateId == currentTaskId) { this.viewers[viewerInfo] = false + EventStreamProcessor.event(ViewerUpdateEvent(this.id)) } } @@ -629,6 +632,7 @@ class InteractiveAsynchronousRunManager( val currentTaskId = this.currentTask(rac)?.taskId if (taskTemplateId == currentTaskId) { this.viewers[viewerInfo] = true + EventStreamProcessor.event(ViewerUpdateEvent(this.id)) } } diff --git a/backend/src/main/kotlin/dev/dres/run/InteractiveSynchronousRunManager.kt b/backend/src/main/kotlin/dev/dres/run/InteractiveSynchronousRunManager.kt index 87fe9eac..28f31b71 100644 --- a/backend/src/main/kotlin/dev/dres/run/InteractiveSynchronousRunManager.kt +++ b/backend/src/main/kotlin/dev/dres/run/InteractiveSynchronousRunManager.kt @@ -24,6 +24,7 @@ import dev.dres.run.audit.AuditLogSource import dev.dres.run.audit.AuditLogger import dev.dres.run.eventstream.EventStreamProcessor import dev.dres.run.eventstream.TaskEndEvent +import dev.dres.run.eventstream.ViewerUpdateEvent import dev.dres.run.exceptions.IllegalRunStateException import dev.dres.run.score.scoreboard.Scoreboard import dev.dres.run.updatables.* @@ -469,6 +470,7 @@ class InteractiveSynchronousRunManager( if (taskTemplateId == currentTemplateId) { this.readyLatch.register(viewerInfo) + EventStreamProcessor.event(ViewerUpdateEvent(this.id)) } } @@ -483,6 +485,7 @@ class InteractiveSynchronousRunManager( /* Since the viewer does send the ready message too early, we cannot care whether the task is (already) preparing or not */ this.readyLatch.register(viewerInfo) //avoid redying previously untracked viewers this.readyLatch.setReady(viewerInfo) + EventStreamProcessor.event(ViewerUpdateEvent(this.id)) // } } diff --git a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt index 47f13335..990a3de8 100644 --- a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt +++ b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt @@ -205,14 +205,20 @@ object RunExecutor : StreamEventHandler { } /** - * Builds an [ApiEvaluationState] for the given [InteractiveRunManager] inside a readonly - * transaction. Returns null on any failure so callers can fall back to an HTTP fetch. + * Builds both [ApiEvaluationState] and [ApiEvaluationOverview] in a single readonly + * transaction. Used when a task-state event needs both payloads so we avoid opening + * two separate transactions for the same snapshot. */ - private fun InteractiveRunManager.buildState(): ApiEvaluationState? = runCatching { - this.store.transactional(readonly = true) { - ApiEvaluationState(this@buildState, RunActionContext.INTERNAL) - } - }.onFailure { logger.warn("Failed to build state diff for WS message: ${it.message}") }.getOrNull() + private fun InteractiveRunManager.buildStateAndOverview(): Pair = + runCatching { + this.store.transactional(readonly = true) { + Pair( + ApiEvaluationState(this@buildStateAndOverview, RunActionContext.INTERNAL), + ApiEvaluationOverview.of(this@buildStateAndOverview) + ) + } + }.onFailure { logger.warn("Failed to build state+overview diff for WS message: ${it.message}") } + .getOrElse { Pair(null, null) } /** * Builds an [ApiEvaluationOverview] for the given [InteractiveRunManager] inside a readonly @@ -239,44 +245,28 @@ object RunExecutor : StreamEventHandler { is TaskStartEvent -> { val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } - ServerMessage( - event.runId, - ServerMessageType.TASK_START, - event.taskId, - state = manager?.buildState(), - overview = manager?.buildOverview() - ) + val (state, overview) = manager?.buildStateAndOverview() ?: Pair(null, null) + ServerMessage(event.runId, ServerMessageType.TASK_START, event.taskId, state = state, overview = overview) } is TaskEndEvent -> { val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } - ServerMessage( - event.runId, - ServerMessageType.TASK_END, - event.taskId, - state = manager?.buildState(), - overview = manager?.buildOverview() - ) + val (state, overview) = manager?.buildStateAndOverview() ?: Pair(null, null) + ServerMessage(event.runId, ServerMessageType.TASK_END, event.taskId, state = state, overview = overview) } is ScoreUpdateEvent -> { val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } - ServerMessage( - event.runId, - ServerMessageType.COMPETITION_UPDATE, - overview = manager?.buildOverview() - ) + ServerMessage(event.runId, ServerMessageType.COMPETITION_UPDATE, overview = manager?.buildOverview()) } is SubmissionEvent -> { val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } - ServerMessage( - event.runId, - ServerMessageType.TASK_UPDATED, - overview = manager?.buildOverview() - ) + ServerMessage(event.runId, ServerMessageType.TASK_UPDATED, overview = manager?.buildOverview()) } + is ViewerUpdateEvent -> ServerMessage(event.runId, ServerMessageType.VIEWER_UPDATE) + else -> null } diff --git a/backend/src/main/kotlin/dev/dres/run/eventstream/StreamEvent.kt b/backend/src/main/kotlin/dev/dres/run/eventstream/StreamEvent.kt index 8b6b9e40..22afbaa3 100644 --- a/backend/src/main/kotlin/dev/dres/run/eventstream/StreamEvent.kt +++ b/backend/src/main/kotlin/dev/dres/run/eventstream/StreamEvent.kt @@ -23,4 +23,5 @@ class QueryResultLogEvent(session: String?, val runId: EvaluationId, val queryRe class InvalidRequestEvent(session: String?, val runId: EvaluationId, val requestData: String) : StreamEvent(session = session) class ScoreUpdateEvent(val runId: EvaluationId, val scoreboardName: String, val scores: List) : StreamEvent() { constructor(runId: EvaluationId, scoreboardName: String, scores: Map): this(runId, scoreboardName, scores.map { Score(it.key, it.value) }) -} \ No newline at end of file +} +class ViewerUpdateEvent(val runId: EvaluationId) : StreamEvent() \ No newline at end of file diff --git a/frontend/src/app/model/ws/server-message-type.enum.ts b/frontend/src/app/model/ws/server-message-type.enum.ts index b67f1262..8240136b 100644 --- a/frontend/src/app/model/ws/server-message-type.enum.ts +++ b/frontend/src/app/model/ws/server-message-type.enum.ts @@ -8,6 +8,7 @@ export namespace ServerMessageType { | 'TASK_START' | 'TASK_UPDATED' | 'TASK_END' + | 'VIEWER_UPDATE' | 'PING'; export const ServerMessageTypeEnum = { COMPETITION_START: 'COMPETITION_START' as ServerMessageTypeEnum, @@ -17,6 +18,7 @@ export namespace ServerMessageType { TASK_START: 'TASK_START' as ServerMessageTypeEnum, TASK_UPDATED: 'TASK_UPDATED' as ServerMessageTypeEnum, TASK_END: 'TASK_END' as ServerMessageTypeEnum, + VIEWER_UPDATE: 'VIEWER_UPDATE' as ServerMessageTypeEnum, PING: 'PING' as ServerMessageTypeEnum, }; export const ServerMessageTypes: ServerMessageTypeEnum[] = [ @@ -27,6 +29,7 @@ export namespace ServerMessageType { 'TASK_START', 'TASK_UPDATED', 'TASK_END', + 'VIEWER_UPDATE', 'PING', ]; } diff --git a/frontend/src/app/run/run-admin-view.component.ts b/frontend/src/app/run/run-admin-view.component.ts index b7bfc03e..f73fedf7 100644 --- a/frontend/src/app/run/run-admin-view.component.ts +++ b/frontend/src/app/run/run-admin-view.component.ts @@ -78,13 +78,9 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { ].includes(msg.type)) ); - /* Any WS event that previously triggered a viewer-list refresh. */ + /* Fires only when a viewer actually connects or signals ready. */ const viewerWs$ = this.wsService.messages$.pipe( - filter((msg) => [ - ServerMessageType.ServerMessageTypeEnum.TASK_START, - ServerMessageType.ServerMessageTypeEnum.TASK_END, - ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, - ].includes(msg.type)) + filter((msg) => msg.type === ServerMessageType.ServerMessageTypeEnum.VIEWER_UPDATE) ); this.runId = this.activeRoute.params.pipe(map((a) => a.runId)); From 84a1ef3ce385d6c06f83db991c975bf9c1649eae Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Fri, 19 Jun 2026 18:35:03 +0100 Subject: [PATCH 13/16] runviewer gets ws state directly and submission updates scoped to one team --- .../evaluation/websocket/ServerMessage.kt | 10 ++- .../main/kotlin/dev/dres/run/RunExecutor.kt | 28 +++++- .../dres/run/RunExecutorWebSocketTest.kt | 13 +++ .../model/ws/ws-server-message.interface.ts | 7 +- .../src/app/run/run-admin-view.component.ts | 45 +++++++--- .../run-async-admin-view.component.ts | 47 +++++++--- frontend/src/app/utilities/api.utilities.ts | 31 ++++--- .../src/app/viewer/run-viewer.component.ts | 86 ++++++++++++++----- 8 files changed, 201 insertions(+), 66 deletions(-) diff --git a/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt b/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt index 24b26d9f..5d36d613 100644 --- a/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt +++ b/backend/src/main/kotlin/dev/dres/api/rest/types/evaluation/websocket/ServerMessage.kt @@ -2,6 +2,7 @@ package dev.dres.api.rest.types.evaluation.websocket import dev.dres.api.rest.types.evaluation.ApiEvaluationOverview import dev.dres.api.rest.types.evaluation.ApiEvaluationState +import dev.dres.api.rest.types.evaluation.ApiTeamTaskOverview import dev.dres.data.model.run.interfaces.EvaluationId import dev.dres.data.model.run.interfaces.TaskId @@ -9,10 +10,12 @@ import dev.dres.data.model.run.interfaces.TaskId * Message send by the DRES server via WebSocket to inform clients about the state of the run. * * The optional [state] and [overview] fields carry a state diff so that receivers can update - * their local representation without issuing a separate HTTP request. + * their local representation without issuing a separate HTTP request. [teamOverview] carries a + * scoped diff for a single team (e.g. in response to a submission) so that receivers don't need + * to be sent every team's overview just because one team's changed. * * @author Ralph Gasser - * @version 1.3.0 + * @version 1.4.0 */ data class ServerMessage( val evaluationId: EvaluationId, @@ -20,6 +23,7 @@ data class ServerMessage( val taskId: TaskId? = null, val timestamp: Long = System.currentTimeMillis(), val state: ApiEvaluationState? = null, - val overview: ApiEvaluationOverview? = null + val overview: ApiEvaluationOverview? = null, + val teamOverview: ApiTeamTaskOverview? = null ) diff --git a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt index 990a3de8..1075faf4 100644 --- a/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt +++ b/backend/src/main/kotlin/dev/dres/run/RunExecutor.kt @@ -5,12 +5,15 @@ import dev.dres.api.rest.AccessManager import dev.dres.api.rest.types.ViewerInfo import dev.dres.api.rest.types.evaluation.ApiEvaluationOverview import dev.dres.api.rest.types.evaluation.ApiEvaluationState +import dev.dres.api.rest.types.evaluation.ApiTaskOverview +import dev.dres.api.rest.types.evaluation.ApiTeamTaskOverview import dev.dres.api.rest.types.evaluation.websocket.ClientMessage import dev.dres.api.rest.types.evaluation.websocket.ClientMessageType import dev.dres.api.rest.types.evaluation.websocket.ServerMessage import dev.dres.api.rest.types.evaluation.websocket.ServerMessageType import dev.dres.data.model.run.* import dev.dres.data.model.run.interfaces.EvaluationId +import dev.dres.data.model.template.team.TeamId import dev.dres.run.eventstream.* import dev.dres.run.validation.interfaces.JudgementValidator import dev.dres.utilities.extensions.sessionToken @@ -230,13 +233,33 @@ object RunExecutor : StreamEventHandler { } }.onFailure { logger.warn("Failed to build overview diff for WS message: ${it.message}") }.getOrNull() + /** + * Builds an [ApiTeamTaskOverview] scoped to the given [teamId] inside a readonly transaction. + * + * Used so that a submission from a single team only ever broadcasts that team's overview + * instead of rebuilding and sending every team's overview. Returns null on any failure so + * callers can fall back to an HTTP fetch. + */ + private fun InteractiveRunManager.buildTeamOverview(teamId: TeamId): ApiTeamTaskOverview? = runCatching { + this.store.transactional(readonly = true) { + val tasks = when (val manager = this@buildTeamOverview) { + is InteractiveSynchronousRunManager -> manager.evaluation.taskRuns.asSequence().map { ApiTaskOverview(it) }.toList() + is InteractiveAsynchronousRunManager -> manager.evaluation.taskRuns.asSequence().filter { it.teamId == teamId }.map { ApiTaskOverview(it) }.toList() + else -> throw IllegalStateException("Unsupported run manager type") //should never happen + } + ApiTeamTaskOverview(teamId, tasks) + } + }.onFailure { logger.warn("Failed to build team overview diff for WS message: ${it.message}") }.getOrNull() + /** * Maps a [StreamEvent] to the [ServerMessage] that should be broadcast, or null * if the event type requires no WebSocket notification. * * When the event carries enough information, [ServerMessage.state] and/or * [ServerMessage.overview] are populated so that receivers can update their local - * state without issuing a separate HTTP request. + * state without issuing a separate HTTP request. [SubmissionEvent]s populate the more + * narrowly scoped [ServerMessage.teamOverview] instead, since only one team's overview + * actually changes as a result. */ internal fun eventToMessage(event: StreamEvent): ServerMessage? = when (event) { is RunStartEvent -> ServerMessage(event.runId, ServerMessageType.COMPETITION_START) @@ -262,7 +285,8 @@ object RunExecutor : StreamEventHandler { is SubmissionEvent -> { val manager = this.runManagerLock.read { runManagers[event.runId] as? InteractiveRunManager } - ServerMessage(event.runId, ServerMessageType.TASK_UPDATED, overview = manager?.buildOverview()) + val teamOverview = event.submission.teamId?.let { manager?.buildTeamOverview(it) } + ServerMessage(event.runId, ServerMessageType.TASK_UPDATED, teamOverview = teamOverview) } is ViewerUpdateEvent -> ServerMessage(event.runId, ServerMessageType.VIEWER_UPDATE) diff --git a/backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt b/backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt index df13e64a..a1214507 100644 --- a/backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt +++ b/backend/src/test/kotlin/dres/run/RunExecutorWebSocketTest.kt @@ -85,6 +85,19 @@ class RunExecutorWebSocketTest { assertEquals(runId, msg.evaluationId) } + @Test + fun `SubmissionEvent carries no full overview, only a scoped teamOverview`() { + // No run manager is registered for runId in this test, and the fixture submission has no + // teamId, so both end up null here -- this asserts the *shape* (overview unset, teamOverview + // the only possible diff carrier) rather than relying on a live run manager. + val msg = RunExecutor.eventToMessage( + SubmissionEvent("session-1", runId, ApiClientSubmission(answerSets = emptyList())) + ) + assertNotNull(msg) + assertNull(msg!!.overview) + assertNull(msg.teamOverview) + } + @Test fun `unhandled event type maps to null`() { val msg = RunExecutor.eventToMessage(InvalidRequestEvent("session-1", runId, "bad data")) diff --git a/frontend/src/app/model/ws/ws-server-message.interface.ts b/frontend/src/app/model/ws/ws-server-message.interface.ts index 8cb0d4cd..8511b0db 100644 --- a/frontend/src/app/model/ws/ws-server-message.interface.ts +++ b/frontend/src/app/model/ws/ws-server-message.interface.ts @@ -3,11 +3,12 @@ import ServerMessageTypeEnum = ServerMessageType.ServerMessageTypeEnum; import { IWsMessage } from './ws-message.interface'; import { ApiEvaluationState } from '../../../../openapi/model/apiEvaluationState'; import { ApiEvaluationOverview } from '../../../../openapi/model/apiEvaluationOverview'; +import { ApiTeamTaskOverview } from '../../../../openapi/model/apiTeamTaskOverview'; /** * Structure of a ServerMessage as generated by the DRES server. * - * When state or overview are present, consumers should apply them directly + * When state, overview or teamOverview are present, consumers should apply them directly * to their local state instead of issuing a separate HTTP request. */ export interface IWsServerMessage extends IWsMessage { @@ -17,6 +18,8 @@ export interface IWsServerMessage extends IWsMessage { timestamp: number; /** Embedded evaluation state diff. Present on TASK_START, TASK_END. */ state?: ApiEvaluationState; - /** Embedded evaluation overview diff. Present on TASK_START, TASK_END, COMPETITION_UPDATE, TASK_UPDATED. */ + /** Embedded evaluation overview diff. Present on TASK_START, TASK_END, COMPETITION_UPDATE. */ overview?: ApiEvaluationOverview; + /** Embedded single-team overview diff. Present on TASK_UPDATED (submissions); scoped to the submitting team. */ + teamOverview?: ApiTeamTaskOverview; } diff --git a/frontend/src/app/run/run-admin-view.component.ts b/frontend/src/app/run/run-admin-view.component.ts index f73fedf7..5198cf8a 100644 --- a/frontend/src/app/run/run-admin-view.component.ts +++ b/frontend/src/app/run/run-admin-view.component.ts @@ -2,12 +2,13 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { AppConfig } from '../app.config'; import { combineLatest, merge, mergeMap, Observable, of, Subject, timer} from 'rxjs'; -import { catchError, filter, map, shareReplay, switchMap, take } from "rxjs/operators"; +import { catchError, filter, map, scan, shareReplay, switchMap, take } from "rxjs/operators"; import { WebSocketService } from '../services/websocket.service'; import { ServerMessageType } from '../model/ws/server-message-type.enum'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatDialog } from '@angular/material/dialog'; import { RunInfoOverviewTuple } from './admin-run-list.component'; +import { mergeTeamOverview } from '../utilities/api.utilities'; import { ApiEvaluationInfo, ApiEvaluationState, @@ -68,16 +69,20 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { ].includes(msg.type)) ); - /* WS messages that carry an overview diff (submissions, scores, task transitions). */ + /* WS messages that may carry a full overview diff (task transitions, score changes). */ const overviewWs$ = this.wsService.messages$.pipe( filter((msg) => [ ServerMessageType.ServerMessageTypeEnum.TASK_START, ServerMessageType.ServerMessageTypeEnum.TASK_END, - ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, ].includes(msg.type)) ); + /* WS messages that carry a single-team overview diff (a submission from that team). */ + const teamOverviewWs$ = this.wsService.messages$.pipe( + filter((msg) => msg.type === ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED) + ); + /* Fires only when a viewer actually connects or signals ready. */ const viewerWs$ = this.wsService.messages$.pipe( filter((msg) => msg.type === ServerMessageType.ServerMessageTypeEnum.VIEWER_UPDATE) @@ -197,20 +202,36 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { filter((q) => q != null) ), merge( - /* Safety fallback: full HTTP fetch every 30 s or on manual refresh. */ - merge(timer(0, 30_000), this.refreshSubject).pipe( - switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + /* Safety fallback: full HTTP fetch every 30 s, on manual refresh, or whenever a + relevant WS message arrives without a usable payload. */ + merge( + timer(0, 30_000), + this.refreshSubject, + overviewWs$.pipe(filter((msg) => msg.overview == null)), + teamOverviewWs$.pipe(filter((msg) => msg.teamOverview == null)) + ).pipe( + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)), + map((overview) => ({ full: overview } as { full?: ApiEvaluationOverview; team?: ApiTeamTaskOverview })) ), - /* Apply diff directly when the WS message carries overview — no HTTP needed. */ + /* Apply diff directly when the WS message carries a full overview — no HTTP needed. */ overviewWs$.pipe( filter((msg) => msg.overview != null), - map((msg) => msg.overview as ApiEvaluationOverview) + map((msg) => ({ full: msg.overview as ApiEvaluationOverview })) ), - /* Fallback HTTP for events whose overview payload is absent. */ - overviewWs$.pipe( - filter((msg) => msg.overview == null), - switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + /* Apply a scoped single-team diff directly — no HTTP needed, and no need to touch + any other team's overview. */ + teamOverviewWs$.pipe( + filter((msg) => msg.teamOverview != null), + map((msg) => ({ team: msg.teamOverview as ApiTeamTaskOverview })) ) + ).pipe( + scan((acc: ApiEvaluationOverview, update: { full?: ApiEvaluationOverview; team?: ApiTeamTaskOverview }) => { + if (update.full) { + return update.full; + } + return acc ? mergeTeamOverview(acc, update.team) : acc; + }, null as ApiEvaluationOverview), + filter((overview) => overview != null) ), ]) ), diff --git a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts index 90b815e9..6e81c97a 100644 --- a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts +++ b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts @@ -4,10 +4,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AppConfig } from '../../app.config'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatDialog } from '@angular/material/dialog'; -import { catchError, filter, map, shareReplay, switchMap, take } from 'rxjs/operators'; +import { catchError, filter, map, scan, shareReplay, switchMap, take } from 'rxjs/operators'; import { WebSocketService } from '../../services/websocket.service'; import { ServerMessageType } from '../../model/ws/server-message-type.enum'; import { RunInfoOverviewTuple } from '../admin-run-list.component'; +import { mergeTeamOverview } from '../../utilities/api.utilities'; import { MatAccordion } from '@angular/material/expansion'; import { ApiEvaluationOverview, @@ -59,16 +60,20 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { ) { this.activeRoute.params.pipe(map((a) => a.runId)).subscribe(this.runId); - /* WS messages that carry an overview diff (submissions, scores, task transitions). */ + /* WS messages that may carry a full overview diff (task transitions, score changes). */ const overviewWs$ = this.wsService.messages$.pipe( filter((msg) => [ ServerMessageType.ServerMessageTypeEnum.TASK_START, ServerMessageType.ServerMessageTypeEnum.TASK_END, - ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, ].includes(msg.type)) ); + /* WS messages that carry a single-team overview diff (a submission from that team). */ + const teamOverviewWs$ = this.wsService.messages$.pipe( + filter((msg) => msg.type === ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED) + ); + this.run = this.runId.pipe( switchMap((runId) => combineLatest([ @@ -86,20 +91,36 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { filter((q) => q != null) ), merge( - /* Safety fallback: full HTTP fetch every 30 s or on manual update trigger. */ - merge(timer(0, 30_000), this.update).pipe( - switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + /* Safety fallback: full HTTP fetch every 30 s, on manual update trigger, or whenever + a relevant WS message arrives without a usable payload. */ + merge( + timer(0, 30_000), + this.update, + overviewWs$.pipe(filter((msg) => msg.overview == null)), + teamOverviewWs$.pipe(filter((msg) => msg.teamOverview == null)) + ).pipe( + switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)), + map((overview) => ({ full: overview } as { full?: ApiEvaluationOverview; team?: ApiTeamTaskOverview })) ), - /* Apply diff directly when the WS message carries overview — no HTTP needed. */ + /* Apply diff directly when the WS message carries a full overview — no HTTP needed. */ overviewWs$.pipe( filter((msg) => msg.overview != null), - map((msg) => msg.overview as ApiEvaluationOverview) + map((msg) => ({ full: msg.overview as ApiEvaluationOverview })) ), - /* Fallback HTTP for events whose overview payload is absent. */ - overviewWs$.pipe( - filter((msg) => msg.overview == null), - switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview(runId)) + /* Apply a scoped single-team diff directly — no HTTP needed, and no need to touch + any other team's overview. */ + teamOverviewWs$.pipe( + filter((msg) => msg.teamOverview != null), + map((msg) => ({ team: msg.teamOverview as ApiTeamTaskOverview })) ) + ).pipe( + scan((acc: ApiEvaluationOverview, update: { full?: ApiEvaluationOverview; team?: ApiTeamTaskOverview }) => { + if (update.full) { + return update.full; + } + return acc ? mergeTeamOverview(acc, update.team) : acc; + }, null as ApiEvaluationOverview), + filter((overview) => overview != null) ), ]) ), @@ -116,7 +137,7 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { shareReplay({ bufferSize: 1, refCount: true }) /* Cache last successful loading. */ ); - this.taskSubmissionCounts = merge(timer(0, 30_000), this.update, overviewWs$).pipe( + this.taskSubmissionCounts = merge(timer(0, 30_000), this.update, overviewWs$, teamOverviewWs$).pipe( switchMap(() => this.run.pipe(take(1))), switchMap((run) => { const runId = this.runId.getValue(); diff --git a/frontend/src/app/utilities/api.utilities.ts b/frontend/src/app/utilities/api.utilities.ts index 250c81cc..31cb43a3 100644 --- a/frontend/src/app/utilities/api.utilities.ts +++ b/frontend/src/app/utilities/api.utilities.ts @@ -1,10 +1,4 @@ -import { - ApiEvaluation, - ApiEvaluationInfo, - ApiEvaluationOverview, - ApiEvaluationTemplate, - DownloadService, -} from '../../../openapi'; +import { ApiEvaluation, ApiEvaluationInfo, ApiEvaluationOverview, ApiEvaluationTemplate, ApiTeamTaskOverview, DownloadService } from "../../../openapi"; /** * Type guard for ApiEvaluationTemplate. @@ -25,8 +19,23 @@ export function instanceOfTemplate(obj: any): obj is ApiEvaluationTemplate { return idCheck && minimumPropsCheck; } -export function instanceOfEvaluation(obj: any): obj is ApiEvaluation { - const idCheck = 'evaluationId' in obj || 'id' in obj; - const minimumPropsCheck = 'name' in obj && 'type' in obj && 'template' in obj && 'created' in obj && 'tasks' in obj; - return idCheck && minimumPropsCheck; +export function instanceOfEvaluation(obj: any): obj is ApiEvaluation{ + const idCheck = 'evaluationId' in obj || 'id' in obj + const minimumPropsCheck = 'name' in obj && 'type' in obj && 'template' in obj && 'created' in obj && 'tasks' in obj + return idCheck && minimumPropsCheck +} + +/** + * Returns a copy of `overview` with `teamOverview` inserted in place of the entry for the same + * team (or appended, if no such entry exists yet), leaving every other team's overview untouched. + * + * Used to apply the single-team overview diffs carried by TASK_UPDATED WebSocket messages without + * having to refetch every team's overview just because one team submitted. + */ +export function mergeTeamOverview(overview: ApiEvaluationOverview, teamOverview: ApiTeamTaskOverview): ApiEvaluationOverview { + const exists = overview.teamOverviews.some((t) => t.teamId === teamOverview.teamId); + const teamOverviews = exists + ? overview.teamOverviews.map((t) => (t.teamId === teamOverview.teamId ? teamOverview : t)) + : [...overview.teamOverviews, teamOverview]; + return { ...overview, teamOverviews }; } diff --git a/frontend/src/app/viewer/run-viewer.component.ts b/frontend/src/app/viewer/run-viewer.component.ts index d6c6c0f5..69c4df69 100644 --- a/frontend/src/app/viewer/run-viewer.component.ts +++ b/frontend/src/app/viewer/run-viewer.component.ts @@ -1,7 +1,15 @@ -import { AfterViewInit, Component, Inject, OnDestroy, OnInit, ViewContainerRef, DOCUMENT } from '@angular/core'; -import { ActivatedRoute, ActivationEnd, Params, Router } from '@angular/router'; -import { interval, merge, mergeMap, Observable, of, zip } from 'rxjs'; -import { catchError, filter, map, pairwise, shareReplay, switchMap, tap } from 'rxjs/operators'; +import {AfterViewInit, Component, Inject, OnDestroy, OnInit, ViewContainerRef, DOCUMENT} from '@angular/core'; +import { ActivatedRoute, ActivationEnd, Params, Router } from "@angular/router"; +import {merge, Observable, of, zip} from 'rxjs'; +import { + catchError, + filter, + map, + pairwise, + shareReplay, + switchMap, + tap +} from "rxjs/operators"; import { AppConfig } from '../app.config'; import { WebSocketService } from '../services/websocket.service'; import { ServerMessageType } from '../model/ws/server-message-type.enum'; @@ -152,25 +160,57 @@ export class RunViewerComponent implements OnInit, AfterViewInit, OnDestroy { shareReplay({ bufferSize: 1, refCount: true }) ); - this.state = interval(1000) - .pipe(mergeMap(() => this.evaluationId)) - .pipe( - switchMap((id) => this.runService.getApiV2EvaluationByEvaluationIdState(id)), - catchError((err, o) => { - console.log( - `[RunViewerComponent] There was an error while loading information in the current run state: ${err?.message}` - ); - this.snackBar.open(`There was an error while loading information in the current run: ${err?.message}`, null, { - duration: 5000, - }); - if (err.status === 404) { - this.router.navigate(['/evaluation/list']); - } - return of(null); - }), - filter((q) => q != null), - shareReplay({ bufferSize: 1, refCount: true }) - ); + /* WS messages that carry a state diff directly (TASK_START, TASK_END). */ + const stateWs$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_START, + ServerMessageType.ServerMessageTypeEnum.TASK_END, + ].includes(msg.type)) + ); + + /* WS messages that signal a state change but carry no payload of their own. */ + const stateRefreshWs$ = this.wsService.messages$.pipe( + filter((msg) => [ + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_START, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, + ].includes(msg.type)) + ); + + this.state = this.evaluationId.pipe( + switchMap((id) => + merge( + /* Initial load for this evaluation. */ + this.runService.getApiV2EvaluationByEvaluationIdState(id), + /* Apply diff directly when the WS message carries state — no HTTP needed. */ + stateWs$.pipe( + filter((msg) => msg.state != null), + map((msg) => msg.state as ApiEvaluationState) + ), + /* Fallback HTTP fetch for state-carrying messages without a payload, and for + messages that signal a state change without carrying one. */ + merge(stateWs$.pipe(filter((msg) => msg.state == null)), stateRefreshWs$).pipe( + switchMap(() => this.runService.getApiV2EvaluationByEvaluationIdState(id)) + ) + ) + ), + catchError((err, o) => { + console.log( + `[RunViewerComponent] There was an error while loading information in the current run state: ${err?.message}` + ); + this.snackBar.open(`There was an error while loading information in the current run: ${err?.message}`, null, { + duration: 5000, + }); + if (err.status === 404) { + this.router.navigate(['/evaluation/list']); + } + return of(null); + }), + filter((q) => q != null), + shareReplay({ bufferSize: 1, refCount: true }) + ); /* Basic observable that fires when a task starts. */ this.taskStarted = merge(of(null as ApiEvaluationState), this.state).pipe( From 5cc53ae70ec3ab37a2a5bba7e587a4d393730555 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Sat, 20 Jun 2026 11:39:04 +0100 Subject: [PATCH 14/16] Fixs in taskstateWs and overviewWs --- frontend/src/app/run/run-admin-view.component.ts | 13 ++++++++++--- .../run-async-admin-view.component.ts | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/run/run-admin-view.component.ts b/frontend/src/app/run/run-admin-view.component.ts index 5198cf8a..c2dc1e5c 100644 --- a/frontend/src/app/run/run-admin-view.component.ts +++ b/frontend/src/app/run/run-admin-view.component.ts @@ -59,22 +59,29 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { private dialog: MatDialog, private wsService: WebSocketService ) { - /* WS messages that carry a task-state diff (TASK_START, TASK_END, TASK_PREPARE). */ + /* WS messages that signal the run's state may have changed (some carry a state diff + directly, others — TASK_PREPARE, TASK_UPDATED, COMPETITION_UPDATE, COMPETITION_END — + never do and always fall back to an HTTP fetch). */ const taskStateWs$ = this.wsService.messages$.pipe( filter((msg) => [ ServerMessageType.ServerMessageTypeEnum.TASK_START, ServerMessageType.ServerMessageTypeEnum.TASK_END, ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, + ServerMessageType.ServerMessageTypeEnum.TASK_UPDATED, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, ].includes(msg.type)) ); - /* WS messages that may carry a full overview diff (task transitions, score changes). */ + /* WS messages that may carry a full overview diff (task transitions, score changes), + plus the two lifecycle types that never carry one and always need an HTTP refetch. */ const overviewWs$ = this.wsService.messages$.pipe( filter((msg) => [ ServerMessageType.ServerMessageTypeEnum.TASK_START, ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, ].includes(msg.type)) ); @@ -242,7 +249,7 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { ); this.viewers = this.runId.pipe( - mergeMap((runId) => merge(timer(0, 30_000), viewerWs$).pipe(switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)))) + mergeMap((runId) => merge(timer(0, 30_000), viewerWs$, taskStateWs$).pipe(switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)))) ); this.teams = this.run.pipe( diff --git a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts index 6e81c97a..14c0443d 100644 --- a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts +++ b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts @@ -60,12 +60,15 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { ) { this.activeRoute.params.pipe(map((a) => a.runId)).subscribe(this.runId); - /* WS messages that may carry a full overview diff (task transitions, score changes). */ + /* WS messages that may carry a full overview diff (task transitions, score changes), + plus the two lifecycle types that never carry one and always need an HTTP refetch. */ const overviewWs$ = this.wsService.messages$.pipe( filter((msg) => [ ServerMessageType.ServerMessageTypeEnum.TASK_START, ServerMessageType.ServerMessageTypeEnum.TASK_END, + ServerMessageType.ServerMessageTypeEnum.TASK_PREPARE, ServerMessageType.ServerMessageTypeEnum.COMPETITION_UPDATE, + ServerMessageType.ServerMessageTypeEnum.COMPETITION_END, ].includes(msg.type)) ); From 4add92fac9015772b10b8b5f64c938ff475f33a2 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Mon, 22 Jun 2026 09:49:36 +0100 Subject: [PATCH 15/16] Resync state via REST after Ws reconnects to avoid missing events --- .../app/run/run-admin-view.component.spec.ts | 24 +++++++++++ .../src/app/run/run-admin-view.component.ts | 13 +++--- .../run-async-admin-view.component.spec.ts | 21 +++++++++ .../run-async-admin-view.component.ts | 8 ++-- .../app/services/websocket.service.spec.ts | 43 +++++++++++++++++++ .../src/app/services/websocket.service.ts | 13 ++++++ .../app/viewer/run-viewer.component.spec.ts | 16 +++++++ .../src/app/viewer/run-viewer.component.ts | 7 +-- 8 files changed, 134 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/run/run-admin-view.component.spec.ts b/frontend/src/app/run/run-admin-view.component.spec.ts index e888e372..7f868f42 100644 --- a/frontend/src/app/run/run-admin-view.component.spec.ts +++ b/frontend/src/app/run/run-admin-view.component.spec.ts @@ -42,12 +42,15 @@ describe('RunAdminViewComponent WebSocket wiring', () => { let runAdminService: jasmine.SpyObj; let templateService: jasmine.SpyObj; let messages$: Subject; + let reconnected$: Subject; beforeEach(() => { messages$ = new Subject(); + reconnected$ = new Subject(); wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { messages$: messages$.asObservable(), + reconnected$: reconnected$.asObservable(), }); runService = jasmine.createSpyObj('EvaluationService', [ @@ -150,4 +153,25 @@ describe('RunAdminViewComponent WebSocket wiring', () => { discardPeriodicTasks(); })); }); + + // ── resync on reconnect ───────────────────────────────────────────────────── + + it('refreshes run state, overview and viewer list when the WebSocket reconnects', fakeAsync(() => { + component.ngOnInit(); + component.run.subscribe(); + component.runOverview.subscribe(); + component.viewers.subscribe(); + tick(); + runService.getApiV2EvaluationByEvaluationIdState.calls.reset(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview.calls.reset(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList.calls.reset(); + + reconnected$.next(); + tick(); + + expect(runService.getApiV2EvaluationByEvaluationIdState).toHaveBeenCalledWith('eval-1'); + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview).toHaveBeenCalledWith('eval-1'); + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList).toHaveBeenCalledWith('eval-1'); + discardPeriodicTasks(); + })); }); diff --git a/frontend/src/app/run/run-admin-view.component.ts b/frontend/src/app/run/run-admin-view.component.ts index c2dc1e5c..b69ee97d 100644 --- a/frontend/src/app/run/run-admin-view.component.ts +++ b/frontend/src/app/run/run-admin-view.component.ts @@ -115,8 +115,9 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { filter((q) => q != null) ), merge( - /* Safety fallback: full HTTP fetch every 30 s or on manual refresh. */ - merge(timer(0, 30_000), this.refreshSubject).pipe( + /* Safety fallback: full HTTP fetch every 30 s, on manual refresh, or after a + WebSocket reconnect — any event missed while disconnected needs a full resync. */ + merge(timer(0, 30_000), this.refreshSubject, this.wsService.reconnected$).pipe( switchMap(() => this.runService.getApiV2EvaluationByEvaluationIdState(runId)) ), /* Apply diff directly when the WS message carries state — no HTTP needed. */ @@ -209,11 +210,13 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { filter((q) => q != null) ), merge( - /* Safety fallback: full HTTP fetch every 30 s, on manual refresh, or whenever a - relevant WS message arrives without a usable payload. */ + /* Safety fallback: full HTTP fetch every 30 s, on manual refresh, whenever a + relevant WS message arrives without a usable payload, or after a WebSocket + reconnect — any event missed while disconnected needs a full resync. */ merge( timer(0, 30_000), this.refreshSubject, + this.wsService.reconnected$, overviewWs$.pipe(filter((msg) => msg.overview == null)), teamOverviewWs$.pipe(filter((msg) => msg.teamOverview == null)) ).pipe( @@ -249,7 +252,7 @@ export class RunAdminViewComponent implements OnInit, OnDestroy { ); this.viewers = this.runId.pipe( - mergeMap((runId) => merge(timer(0, 30_000), viewerWs$, taskStateWs$).pipe(switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)))) + mergeMap((runId) => merge(timer(0, 30_000), viewerWs$, taskStateWs$, this.wsService.reconnected$).pipe(switchMap(() => this.runAdminService.getApiV2EvaluationAdminByEvaluationIdViewerList(runId)))) ); this.teams = this.run.pipe( diff --git a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts index 07b346a8..f929d336 100644 --- a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts +++ b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.spec.ts @@ -47,12 +47,15 @@ describe('RunAsyncAdminViewComponent WebSocket wiring', () => { let evaluationService: jasmine.SpyObj; let runAdminService: jasmine.SpyObj; let messages$: Subject; + let reconnected$: Subject; beforeEach(() => { messages$ = new Subject(); + reconnected$ = new Subject(); wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { messages$: messages$.asObservable(), + reconnected$: reconnected$.asObservable(), }); evaluationService = jasmine.createSpyObj('EvaluationService', ['getApiV2EvaluationByEvaluationIdInfo']); @@ -156,4 +159,22 @@ describe('RunAsyncAdminViewComponent WebSocket wiring', () => { discardPeriodicTasks(); })); }); + + // ── resync on reconnect ───────────────────────────────────────────────────── + + it('refreshes the run overview and task submission counts when the WebSocket reconnects', fakeAsync(() => { + component.ngAfterViewInit(); + component.run.subscribe(); + component.taskSubmissionCounts.subscribe(); + tick(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview.calls.reset(); + runAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId.calls.reset(); + + reconnected$.next(); + tick(); + + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdOverview).toHaveBeenCalledWith('eval-1'); + expect(runAdminService.getApiV2EvaluationAdminByEvaluationIdSubmissionListByTemplateId).toHaveBeenCalledWith('eval-1', 't1'); + discardPeriodicTasks(); + })); }); diff --git a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts index 14c0443d..53736a90 100644 --- a/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts +++ b/frontend/src/app/run/run-async-admin-view/run-async-admin-view.component.ts @@ -94,11 +94,13 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { filter((q) => q != null) ), merge( - /* Safety fallback: full HTTP fetch every 30 s, on manual update trigger, or whenever - a relevant WS message arrives without a usable payload. */ + /* Safety fallback: full HTTP fetch every 30 s, on manual update trigger, whenever + a relevant WS message arrives without a usable payload, or after a WebSocket + reconnect — any event missed while disconnected needs a full resync. */ merge( timer(0, 30_000), this.update, + this.wsService.reconnected$, overviewWs$.pipe(filter((msg) => msg.overview == null)), teamOverviewWs$.pipe(filter((msg) => msg.teamOverview == null)) ).pipe( @@ -140,7 +142,7 @@ export class RunAsyncAdminViewComponent implements AfterViewInit, OnDestroy { shareReplay({ bufferSize: 1, refCount: true }) /* Cache last successful loading. */ ); - this.taskSubmissionCounts = merge(timer(0, 30_000), this.update, overviewWs$, teamOverviewWs$).pipe( + this.taskSubmissionCounts = merge(timer(0, 30_000), this.update, this.wsService.reconnected$, overviewWs$, teamOverviewWs$).pipe( switchMap(() => this.run.pipe(take(1))), switchMap((run) => { const runId = this.runId.getValue(); diff --git a/frontend/src/app/services/websocket.service.spec.ts b/frontend/src/app/services/websocket.service.spec.ts index bf7c7b90..ed73aa74 100644 --- a/frontend/src/app/services/websocket.service.spec.ts +++ b/frontend/src/app/services/websocket.service.spec.ts @@ -202,6 +202,49 @@ describe('WebSocketService', () => { })); }); + // ── reconnected$ ─────────────────────────────────────────────────────────── + + describe('reconnected$', () => { + it('does not emit on the initial connection', () => { + const reconnects: void[] = []; + service.reconnected$.subscribe(() => reconnects.push(undefined)); + + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + + expect(reconnects.length).toBe(0); + }); + + it('emits when the socket re-opens after an unexpected drop', fakeAsync(() => { + const reconnects: void[] = []; + service.reconnected$.subscribe(() => reconnects.push(undefined)); + + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + + // Simulate unexpected server-side close, then let the auto-reconnect fire. + MockWebSocket.instance!.readyState = WebSocket.CLOSED; + MockWebSocket.instance!.onclose?.(new CloseEvent('close')); + tick(5000); + MockWebSocket.instance!.simulateOpen(); + + expect(reconnects.length).toBe(1); + })); + + it('does not emit again after an explicit disconnect and fresh connect', () => { + const reconnects: void[] = []; + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + service.disconnect(); + + service.reconnected$.subscribe(() => reconnects.push(undefined)); + service.connect('eval-1'); + MockWebSocket.instance!.simulateOpen(); + + expect(reconnects.length).toBe(0); + }); + }); + // ── ping ─────────────────────────────────────────────────────────────────── describe('ping', () => { diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 3ae22e25..716c52cc 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -16,6 +16,7 @@ export class WebSocketService implements OnDestroy { private socket: WebSocket | null = null; private messageSubject = new Subject(); + private reconnectedSubject = new Subject(); private pingSubscription: Subscription | null = null; private reconnectTimeout: ReturnType | null = null; private reconnectAttempts = 0; @@ -24,6 +25,13 @@ export class WebSocketService implements OnDestroy { /** Observable stream of messages pushed by the server. */ readonly messages$: Observable = this.messageSubject.asObservable(); + /** + * Fires whenever the socket re-opens after an unexpected drop (NOT on the initial connect). + * Consumers should treat this as a signal that they may have missed messages while + * disconnected and should refetch their state from the REST API to resync. + */ + readonly reconnected$: Observable = this.reconnectedSubject.asObservable(); + constructor(private config: AppConfig) {} /** @@ -50,12 +58,16 @@ export class WebSocketService implements OnDestroy { this.socket = new WebSocket(url); this.socket.onopen = () => { + const isReconnect = this.reconnectAttempts > 0; this.reconnectAttempts = 0; this.send({ evaluationId: this.currentEvaluationId, type: ClientMessageType.ClientMessageTypeEnum.REGISTER, }); this.startPing(); + if (isReconnect) { + this.reconnectedSubject.next(); + } }; this.socket.onmessage = (event: MessageEvent) => { @@ -135,5 +147,6 @@ export class WebSocketService implements OnDestroy { ngOnDestroy(): void { this.disconnect(); this.messageSubject.complete(); + this.reconnectedSubject.complete(); } } diff --git a/frontend/src/app/viewer/run-viewer.component.spec.ts b/frontend/src/app/viewer/run-viewer.component.spec.ts index c70fbe9d..db052134 100644 --- a/frontend/src/app/viewer/run-viewer.component.spec.ts +++ b/frontend/src/app/viewer/run-viewer.component.spec.ts @@ -40,12 +40,15 @@ describe('RunViewerComponent WebSocket wiring', () => { let wsService: jasmine.SpyObj; let runService: jasmine.SpyObj; let messages$: Subject; + let reconnected$: Subject; beforeEach(() => { messages$ = new Subject(); + reconnected$ = new Subject(); wsService = jasmine.createSpyObj('WebSocketService', ['connect', 'disconnect'], { messages$: messages$.asObservable(), + reconnected$: reconnected$.asObservable(), }); runService = jasmine.createSpyObj('EvaluationService', [ @@ -135,4 +138,17 @@ describe('RunViewerComponent WebSocket wiring', () => { expect(runService.getApiV2EvaluationByEvaluationIdState).not.toHaveBeenCalled(); })); + + // ── resync on reconnect ──────────────────────────────────────────────────── + + it('triggers a state fetch when the WebSocket reconnects', fakeAsync(() => { + component.ngOnInit(); + component.state.subscribe(); + runService.getApiV2EvaluationByEvaluationIdState.calls.reset(); + + reconnected$.next(); + tick(); + + expect(runService.getApiV2EvaluationByEvaluationIdState).toHaveBeenCalledWith('eval-1'); + })); }); diff --git a/frontend/src/app/viewer/run-viewer.component.ts b/frontend/src/app/viewer/run-viewer.component.ts index 69c4df69..048cb702 100644 --- a/frontend/src/app/viewer/run-viewer.component.ts +++ b/frontend/src/app/viewer/run-viewer.component.ts @@ -189,9 +189,10 @@ export class RunViewerComponent implements OnInit, AfterViewInit, OnDestroy { filter((msg) => msg.state != null), map((msg) => msg.state as ApiEvaluationState) ), - /* Fallback HTTP fetch for state-carrying messages without a payload, and for - messages that signal a state change without carrying one. */ - merge(stateWs$.pipe(filter((msg) => msg.state == null)), stateRefreshWs$).pipe( + /* Fallback HTTP fetch for state-carrying messages without a payload, for + messages that signal a state change without carrying one, and after a + WebSocket reconnect — any event missed while disconnected needs a full resync. */ + merge(stateWs$.pipe(filter((msg) => msg.state == null)), stateRefreshWs$, this.wsService.reconnected$).pipe( switchMap(() => this.runService.getApiV2EvaluationByEvaluationIdState(id)) ) ) From 49dfb6cfb48646ee078269fd2129cf8f9c51c0c2 Mon Sep 17 00:00:00 2001 From: mcmahj45 Date: Mon, 22 Jun 2026 11:01:48 +0100 Subject: [PATCH 16/16] merge eitor mistakes fixed --- frontend/src/app/run/run-admin-view.component.ts | 2 ++ frontend/src/app/viewer/run-viewer.component.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/run/run-admin-view.component.ts b/frontend/src/app/run/run-admin-view.component.ts index b69ee97d..d70ee0cf 100644 --- a/frontend/src/app/run/run-admin-view.component.ts +++ b/frontend/src/app/run/run-admin-view.component.ts @@ -11,10 +11,12 @@ import { RunInfoOverviewTuple } from './admin-run-list.component'; import { mergeTeamOverview } from '../utilities/api.utilities'; import { ApiEvaluationInfo, + ApiEvaluationOverview, ApiEvaluationState, ApiSubmissionInfo, ApiTaskTemplateInfo, ApiTeam, + ApiTeamTaskOverview, ApiViewerInfo, EvaluationAdministratorService, EvaluationService, diff --git a/frontend/src/app/viewer/run-viewer.component.ts b/frontend/src/app/viewer/run-viewer.component.ts index 048cb702..a9d7ed39 100644 --- a/frontend/src/app/viewer/run-viewer.component.ts +++ b/frontend/src/app/viewer/run-viewer.component.ts @@ -8,7 +8,7 @@ import { pairwise, shareReplay, switchMap, - tap + take } from "rxjs/operators"; import { AppConfig } from '../app.config'; import { WebSocketService } from '../services/websocket.service'; @@ -245,7 +245,9 @@ export class RunViewerComponent implements OnInit, AfterViewInit, OnDestroy { /** * Registers this RunViewerComponent on view initialization and creates the WebSocket subscription. */ - ngOnInit(): void {} + ngOnInit(): void { + this.evaluationId.pipe(take(1)).subscribe((id) => this.wsService.connect(id)); + } /** * Prepare the overlay that is being displayed when WebSocket connection times out.