diff --git a/src/app/group-monitor/backend.service.ts b/src/app/group-monitor/backend.service.ts index 504ff6c0331fb2e328fce33dd7432bd7fd636ca2..00e769c1e3956621f086b8482866e50dbee0f5e7 100644 --- a/src/app/group-monitor/backend.service.ts +++ b/src/app/group-monitor/backend.service.ts @@ -1,24 +1,25 @@ import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; -import { Observable, of, Subscription } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { BookletError, GroupData, TestSessionData } from './group-monitor.interfaces'; +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { + BookletError, CommandResponse, GroupData, TestSessionData +} from './group-monitor.interfaces'; import { WebsocketBackendService } from '../shared/websocket-backend.service'; import { ApiError } from '../app.interfaces'; @Injectable() export class BackendService extends WebsocketBackendService<TestSessionData[]> { - public pollingEndpoint = '/monitor/test-sessions'; - public pollingInterval = 5000; - public wsChannelName = 'test-sessions'; - public initialData: TestSessionData[] = []; + pollingEndpoint = '/monitor/test-sessions'; + pollingInterval = 5000; + wsChannelName = 'test-sessions'; + initialData: TestSessionData[] = []; - public observeSessionsMonitor(): Observable<TestSessionData[]> { + observeSessionsMonitor(): Observable<TestSessionData[]> { return this.observeEndpointAndChannel(); } - public getBooklet(bookletName: string): Observable<string|BookletError> { + getBooklet(bookletName: string): Observable<string|BookletError> { const headers = new HttpHeaders({ 'Content-Type': 'text/xml' }).set('Accept', 'text/xml'); const missingFileError: BookletError = { error: 'missing-file', species: null }; const generalError: BookletError = { error: 'general', species: null }; @@ -39,7 +40,7 @@ export class BackendService extends WebsocketBackendService<TestSessionData[]> { ); } - public getGroupData(groupName: string): Observable<GroupData> { + getGroupData(groupName: string): Observable<GroupData> { // TODO error-handling: interceptor should have interfered and moved to error-page ... // https://github.com/iqb-berlin/testcenter-frontend/issues/53 return this.http @@ -50,9 +51,7 @@ export class BackendService extends WebsocketBackendService<TestSessionData[]> { }))); } - public command(keyword: string, args: string[], testIds: number[]): Subscription { - // TODO error-handling: interceptor should have interfered and moved to error-page ... - // https://github.com/iqb-berlin/testcenter-frontend/issues/53 + command(keyword: string, args: string[], testIds: number[]): Observable<CommandResponse> { return this.http .put( `${this.serverUrl}monitor/command`, @@ -63,25 +62,24 @@ export class BackendService extends WebsocketBackendService<TestSessionData[]> { testIds } ) - .pipe(catchError(() => of(false))) - .subscribe(); + .pipe( + map(() => ({ commandType: keyword, testIds })) + ); } - unlock(group_name: string, testIds: number[]): Subscription { - // TODO interceptor should have interfered and moved to error-page ... - // https://github.com/iqb-berlin/testcenter-frontend/issues/53 + unlock(groupName: string, testIds: number[]): Observable<CommandResponse> { return this.http - .post(`${this.serverUrl}monitor/group/${group_name}/tests/unlock`, { testIds }) - .pipe(catchError(() => of(false))) - .subscribe(); + .post(`${this.serverUrl}monitor/group/${groupName}/tests/unlock`, { testIds }) + .pipe( + map(() => ({ commandType: 'unlock', testIds })) + ); } - lock(group_name: string, testIds: number[]): Subscription { - // TODO interceptor should have interfered and moved to error-page ... - // https://github.com/iqb-berlin/testcenter-frontend/issues/53 + lock(groupName: string, testIds: number[]): Observable<CommandResponse> { return this.http - .post(`${this.serverUrl}monitor/group/${group_name}/tests/lock`, { testIds }) - .pipe(catchError(() => of(false))) - .subscribe(); + .post(`${this.serverUrl}monitor/group/${groupName}/tests/lock`, { testIds }) + .pipe( + map(() => ({ commandType: 'unlock', testIds })) + ); } } diff --git a/src/app/group-monitor/group-monitor.component.html b/src/app/group-monitor/group-monitor.component.html index 5f953adca97b9627df3d6ca09ad015c5aeafdd34..1a1bc57239d6ae80a291131f6f88029a1472b8d2 100644 --- a/src/app/group-monitor/group-monitor.component.html +++ b/src/app/group-monitor/group-monitor.component.html @@ -168,9 +168,7 @@ </div> <div class="toolbar-section"> - <div *ngFor="let warning of warnings | keyvalue" class="alert-warning"> - <mat-icon>warning</mat-icon>{{warning.value.text}} - </div> + <alert *ngFor="let m of messages" [text]="m.text" [level]="m.level"></alert> </div> <div class="toolbar-section toolbar-section-bottom"> diff --git a/src/app/group-monitor/group-monitor.component.ts b/src/app/group-monitor/group-monitor.component.ts index 3fa019947aff8b49a46c03a7a61a09b35a3a4a2f..972379757b97e0b9150ed6a47c7d24ba246816dd 100644 --- a/src/app/group-monitor/group-monitor.component.ts +++ b/src/app/group-monitor/group-monitor.component.ts @@ -4,17 +4,17 @@ import { import { ActivatedRoute, Router } from '@angular/router'; import { Sort } from '@angular/material/sort'; import { MatSidenav } from '@angular/material/sidenav'; -import { Observable, Subscription } from 'rxjs'; - +import { interval, Observable, Subscription } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; -import { ConfirmDialogComponent, ConfirmDialogData } from 'iqb-components'; +import { ConfirmDialogComponent, ConfirmDialogData, CustomtextService } from 'iqb-components'; import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatCheckboxChange } from '@angular/material/checkbox'; +import { switchMap } from 'rxjs/operators'; import { BackendService } from './backend.service'; import { GroupData, TestViewDisplayOptions, - TestViewDisplayOptionKey, Selection, TestSession, TestSessionSetStats + TestViewDisplayOptionKey, Selection, TestSession, TestSessionSetStats, CommandResponse, UIMessage } from './group-monitor.interfaces'; import { ConnectionStatus } from '../shared/websocket-backend.service'; import { GroupMonitorService } from './group-monitor.service'; @@ -30,7 +30,8 @@ export class GroupMonitorComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private bs: BackendService, // TODO move completely to service public gms: GroupMonitorService, - private router: Router + private router: Router, + private cts: CustomtextService ) {} ownGroup$: Observable<GroupData>; @@ -54,7 +55,7 @@ export class GroupMonitorComponent implements OnInit, OnDestroy { isScrollable = false; isClosing = false; - warnings: { [key: string]: { text: string, timeout: number } } = {}; + messages: UIMessage[] = []; private routingSubscription: Subscription = null; @@ -74,6 +75,25 @@ export class GroupMonitorComponent implements OnInit, OnDestroy { this.onCheckedChange(stats); }); this.connectionStatus$ = this.bs.connectionStatus$; + this.gms.commandResponses$.subscribe(commandResponse => { + this.messages.push(this.commandResponseToMessage(commandResponse)); + }); + this.gms.commandResponses$ + .pipe(switchMap(() => interval(2000))) + .subscribe(() => this.messages.shift()); + } + + private commandResponseToMessage(commandResponse: CommandResponse): UIMessage { + if (!commandResponse.testIds.length) { + return { + level: 'warning', + text: `No Sessions affected by \`${commandResponse.commandType}\`` + }; + } + return { + level: 'info', + text: `Sent \`${commandResponse.commandType}\` to \`${commandResponse.testIds.length}\` sessions` + }; } ngOnDestroy(): void { @@ -83,6 +103,10 @@ export class GroupMonitorComponent implements OnInit, OnDestroy { this.gms.disconnect(); } + ngAfterViewChecked(): void { + this.isScrollable = this.mainElem.nativeElement.clientHeight < this.mainElem.nativeElement.scrollHeight; + } + private onSessionsUpdate(stats: TestSessionSetStats): void { this.displayOptions.highlightSpecies = (stats.differentBookletSpecies > 1); @@ -110,10 +134,6 @@ export class GroupMonitorComponent implements OnInit, OnDestroy { this.displayOptions[option] = value; } - ngAfterViewChecked(): void { - this.isScrollable = this.mainElem.nativeElement.clientHeight < this.mainElem.nativeElement.scrollHeight; - } - scrollDown(): void { this.mainElem.nativeElement.scrollTo(0, this.mainElem.nativeElement.scrollHeight); } @@ -176,15 +196,18 @@ export class GroupMonitorComponent implements OnInit, OnDestroy { } unlockCommand(): void { - this.gms.testCommandUnlock().add(() => { - const plural = this.gms.sessions.length > 1; - // TODO zahl stimmt nicht - this.addWarning('reload-some-clients', - `${plural ? this.gms.sessions.length : 'Ein'} Test${plural ? 's' : ''} - wurde${plural ? 'n' : ''} entsperrt. ${plural ? 'Die' : 'Der'} Teilnehmer - ${plural ? 'müssen' : 'muss'} die Webseite aufrufen bzw. neuladen, - damit ${plural ? 'die' : 'der'} Test${plural ? 's' : ''} wieder aufgenommen werden kann!`); - }); + this.gms.testCommandUnlock(); + // .subscribe(commandResponse => { + // if (commandResponse.error) { + // const plural = this.gms.sessions.length > 1; + // this.addWarning('reload-some-clients', + // `${plural ? this.gms.sessions.length : 'Ein'} Test${plural ? 's' : ''} + // wurde${plural ? 'n' : ''} entsperrt. ${plural ? 'Die' : 'Der'} Teilnehmer + // ${plural ? 'müssen' : 'muss'} die Webseite aufrufen bzw. neuladen, + // damit ${plural ? 'die' : 'der'} Test${plural ? 's' : ''} wieder aufgenommen werden + // ${plural ? 'können' : 'kann'}!`); + // } + // }); } toggleChecked(checked: boolean, session: TestSession): void { @@ -201,16 +224,6 @@ export class GroupMonitorComponent implements OnInit, OnDestroy { return false; } - private addWarning(key, text): void { - if (typeof this.warnings[key] !== 'undefined') { - window.clearTimeout(this.warnings[key].timeout); - } - this.warnings[key] = { - text, - timeout: window.setTimeout(() => delete this.warnings[key], 30000) - }; - } - toggleAlwaysCheckAll(event: MatSlideToggleChange): void { if (this.gms.checkingOptions.disableAutoCheckAll && !event.checked) { this.gms.checkAll(); diff --git a/src/app/group-monitor/group-monitor.interfaces.ts b/src/app/group-monitor/group-monitor.interfaces.ts index ba994fab80bd757fd460d130e791e76b86fcffb0..87d697b4b4d4b229606c428465a410572d52980d 100644 --- a/src/app/group-monitor/group-monitor.interfaces.ts +++ b/src/app/group-monitor/group-monitor.interfaces.ts @@ -152,3 +152,13 @@ export interface TestSessionSetStats { paused: number; locked: number; } + +export interface UIMessage { + level: 'error' | 'warning' | 'info' | 'success'; + text: string; +} + +export interface CommandResponse { + commandType: string; + testIds: number[]; +} diff --git a/src/app/group-monitor/group-monitor.module.ts b/src/app/group-monitor/group-monitor.module.ts index ffb4577261a70ad7bf9b7e397a86f5073db9df2d..00625b1fd19740a5f662387a029a2a6bc5e13e79 100644 --- a/src/app/group-monitor/group-monitor.module.ts +++ b/src/app/group-monitor/group-monitor.module.ts @@ -24,6 +24,7 @@ import { BookletService } from './booklet.service'; import { TestViewComponent } from './test-view/test-view.component'; import { TestSessionService } from './test-session.service'; import { GroupMonitorService } from './group-monitor.service'; +import { AlertModule } from '../shared/alert/alert.module'; @NgModule({ declarations: [ @@ -48,7 +49,8 @@ import { GroupMonitorService } from './group-monitor.service'; MatSidenavModule, MatCheckboxModule, MatSlideToggleModule, - IqbComponentsModule + IqbComponentsModule, + AlertModule ], providers: [ BackendService, diff --git a/src/app/group-monitor/group-monitor.service.ts b/src/app/group-monitor/group-monitor.service.ts index d5dd4e46563189115ec30c73b17f1d1d88265c6d..19d25251768a43b67180136c099c726112cd85fe 100644 --- a/src/app/group-monitor/group-monitor.service.ts +++ b/src/app/group-monitor/group-monitor.service.ts @@ -12,13 +12,14 @@ import { Selection, CheckingOptions, TestSession, TestSessionFilter, TestSessionSetStats, - TestSessionsSuperStates + TestSessionsSuperStates, CommandResponse } from './group-monitor.interfaces'; /** * func: * # checkAll * - stop / resume usw. ohne erlaubnis-check! sonst macht alwaysAll keinen Sinn + * --> customText und alert kombinieren! * - automatisch den nächsten wählen (?) * - problem beim markieren * tidy: @@ -58,11 +59,16 @@ export class GroupMonitorService { return this._checkedStats$.asObservable(); } + get commandResponses$(): Observable<CommandResponse> { + return this._commandResponses$.asObservable(); + } + private monitor$: Observable<TestSession[]>; private _sessions$: BehaviorSubject<TestSession[]>; private _checked: { [sessionTestSessionId: number]: TestSession } = {}; private _checkedStats$: BehaviorSubject<TestSessionSetStats>; private _sessionsStats$: BehaviorSubject<TestSessionSetStats>; + private _commandResponses$: Subject<CommandResponse>; filterOptions: { label: string, filter: TestSessionFilter, selected: boolean }[] = [ { @@ -101,6 +107,7 @@ export class GroupMonitorService { this._checkedStats$ = new BehaviorSubject<TestSessionSetStats>(GroupMonitorService.getEmptyStats()); this._sessionsStats$ = new BehaviorSubject<TestSessionSetStats>(GroupMonitorService.getEmptyStats()); + this._commandResponses$ = new Subject<CommandResponse>(); this.monitor$ = this.bs.observeSessionsMonitor() .pipe( @@ -244,50 +251,74 @@ export class GroupMonitorService { const testIds = this.checked .filter(TestSessionService.isPaused) .map(session => session.data.testId); - this.bs.command('resume', [], testIds); + if (!testIds.length) { + this._commandResponses$.next({ commandType: 'resume', testIds }); + return; + } + this.bs.command('resume', [], testIds).subscribe( + response => this._commandResponses$.next(response) + ); } testCommandPause(): void { const testIds = this.checked .filter(session => !TestSessionService.isPaused(session)) .map(session => session.data.testId); - this.bs.command('pause', [], testIds); + if (!testIds.length) { + this._commandResponses$.next({ commandType: 'pause', testIds }); + return; + } + this.bs.command('pause', [], testIds).subscribe( + response => this._commandResponses$.next(response) + ); } testCommandGoto(selection: Selection): void { - interface BookletToGotoMap { + const allTestIds: number[] = []; + const groupedByBooklet: { [bookletName: string]: { - sessionIds: number[], + testIds: number[], firstUnitId: string } - } - - const groupedByBooklet: BookletToGotoMap = this.checked - .reduce((agg: BookletToGotoMap, session): BookletToGotoMap => { - if (!agg[session.data.bookletName] && isBooklet(session.booklet)) { - const firstUnit = BookletService.getFirstUnitOfBlock(selection.element.blockId, session.booklet); - if (firstUnit) { - agg[session.data.bookletName] = { - sessionIds: [], - firstUnitId: firstUnit.id - }; - } + } = {}; + + this.checked.forEach(session => { + allTestIds.push(session.data.testId); + if (!groupedByBooklet[session.data.bookletName] && isBooklet(session.booklet)) { + const firstUnit = BookletService.getFirstUnitOfBlock(selection.element.blockId, session.booklet); + if (firstUnit) { + groupedByBooklet[session.data.bookletName] = { + testIds: [], + firstUnitId: firstUnit.id + }; } - agg[session.data.bookletName].sessionIds.push(session.data.testId); - return agg; - }, {}); - - Object.keys(groupedByBooklet) - .forEach(booklet => { - this.bs.command('goto', ['id', groupedByBooklet[booklet].firstUnitId], groupedByBooklet[booklet].sessionIds); + } + groupedByBooklet[session.data.bookletName].testIds.push(session.data.testId); + return groupedByBooklet; + }); + + zip( + ...Object.keys(groupedByBooklet) + .map(key => this.bs.command('goto', ['id', groupedByBooklet[key].firstUnitId], groupedByBooklet[key].testIds)) + ).subscribe(() => { + this._commandResponses$.next({ + commandType: 'goto', + testIds: allTestIds }); + }); } - testCommandUnlock(): Subscription { - const sessionIds = this.checked + testCommandUnlock(): void { + const testIds = this.checked .filter(TestSessionService.isLocked) .map(session => session.data.testId); - return this.bs.unlock(this.groupName, sessionIds); + if (!testIds.length) { + this._commandResponses$.next({ commandType: 'unlock', testIds }); + return; + } + this.bs.unlock(this.groupName, testIds).subscribe( + response => this._commandResponses$.next(response) + ); } isChecked(session: TestSession): boolean { @@ -404,7 +435,7 @@ export class GroupMonitorService { .map(session => session.data.testId); return this.bs.command('terminate', [], getUnlockedConnectedTestIds()) // kill running tests - .add(() => { + .subscribe(() => { setTimeout(() => this.bs.lock(this.groupName, getUnlockedTestIds()), 2000); // lock everything }); }