#103 fixed log retrieval through websocket

This commit is contained in:
Fabio Formosa
2026-05-08 21:41:04 +02:00
parent f6d6cd16e7
commit f6e02ae181
9 changed files with 100 additions and 57 deletions

View File

@@ -5,12 +5,16 @@ import {jest} from '@jest/globals';
describe('LogsPanelComponent', () => { describe('LogsPanelComponent', () => {
const ngZone = {run: jest.fn((fn: () => void) => fn())};
beforeEach(() => ngZone.run.mockClear());
it('should subscribe to the selected trigger logs topic', () => { it('should subscribe to the selected trigger logs topic', () => {
const messages = new Subject<any>(); const messages = new Subject<any>();
const logsRxWebsocketService = { const logsRxWebsocketService = {
watch: jest.fn(() => messages.asObservable()) watch: jest.fn(() => messages.asObservable())
}; };
const component = new LogsPanelComponent(logsRxWebsocketService as any, null); const component = new LogsPanelComponent(logsRxWebsocketService as any, null, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
@@ -26,6 +30,7 @@ describe('LogsPanelComponent', () => {
}; };
messages.next({body: JSON.stringify(logRecord)}); messages.next({body: JSON.stringify(logRecord)});
expect(ngZone.run).toHaveBeenCalled();
expect(component.logs[0]).toEqual({ expect(component.logs[0]).toEqual({
time: logRecord.date.toISOString(), time: logRecord.date.toISOString(),
type: 'INFO', type: 'INFO',
@@ -43,7 +48,7 @@ describe('LogsPanelComponent', () => {
.mockReturnValueOnce(firstMessages.asObservable()) .mockReturnValueOnce(firstMessages.asObservable())
.mockReturnValueOnce(secondMessages.asObservable()) .mockReturnValueOnce(secondMessages.asObservable())
}; };
const component = new LogsPanelComponent(logsRxWebsocketService as any, null); const component = new LogsPanelComponent(logsRxWebsocketService as any, null, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
const firstSubscription = component.topicSubscription; const firstSubscription = component.topicSubscription;
@@ -64,7 +69,7 @@ describe('LogsPanelComponent', () => {
.mockReturnValueOnce(secondMessages.asObservable()) .mockReturnValueOnce(secondMessages.asObservable())
.mockReturnValueOnce(firstMessages.asObservable()) .mockReturnValueOnce(firstMessages.asObservable())
}; };
const component = new LogsPanelComponent(logsRxWebsocketService as any, null); const component = new LogsPanelComponent(logsRxWebsocketService as any, null, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
firstMessages.next({body: JSON.stringify({date: new Date(), type: 'INFO', message: 'first log', threadName: 'worker-1'})}); firstMessages.next({body: JSON.stringify({date: new Date(), type: 'INFO', message: 'first log', threadName: 'worker-1'})});
@@ -89,7 +94,7 @@ describe('LogsPanelComponent', () => {
const logsRxWebsocketService = { const logsRxWebsocketService = {
watch: jest.fn(() => messages.asObservable()) watch: jest.fn(() => messages.asObservable())
}; };
const component = new LogsPanelComponent(logsRxWebsocketService as any, null); const component = new LogsPanelComponent(logsRxWebsocketService as any, null, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
messages.next({body: JSON.stringify({date: new Date(), type: 'INFO', message: 'first log', threadName: 'worker-1'})}); messages.next({body: JSON.stringify({date: new Date(), type: 'INFO', message: 'first log', threadName: 'worker-1'})});
@@ -105,7 +110,7 @@ describe('LogsPanelComponent', () => {
const logsRxWebsocketService = { const logsRxWebsocketService = {
watch: jest.fn() watch: jest.fn()
}; };
const component = new LogsPanelComponent(logsRxWebsocketService as any, null); const component = new LogsPanelComponent(logsRxWebsocketService as any, null, ngZone as any);
expect(() => component.ngOnDestroy()).not.toThrow(); expect(() => component.ngOnDestroy()).not.toThrow();
}); });

View File

@@ -1,4 +1,4 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core'; import {Component, Input, NgZone, OnDestroy, OnInit} from '@angular/core';
import {ApiService} from '../../services'; import {ApiService} from '../../services';
import {LogsRxWebsocketService} from '../../services/logs.rx-websocket.service'; import {LogsRxWebsocketService} from '../../services/logs.rx-websocket.service';
@@ -23,11 +23,12 @@ export class LogsPanelComponent implements OnInit, OnDestroy {
private selectedTriggerKey: TriggerKey; private selectedTriggerKey: TriggerKey;
constructor( constructor(
private logsRxWebsocketService: LogsRxWebsocketService, private logsRxWebsocketService: LogsRxWebsocketService,
private apiService: ApiService private apiService: ApiService,
) { private ngZone: NgZone
} ) {
}
@Input() @Input()
set triggerKey(triggerKey: TriggerKey) { set triggerKey(triggerKey: TriggerKey) {
@@ -58,8 +59,8 @@ export class LogsPanelComponent implements OnInit, OnDestroy {
this._unsubscribeFromTopic(); this._unsubscribeFromTopic();
this.topicSubscription = this.logsRxWebsocketService.watch(`/topic/logs/${triggerKey.name}`) this.topicSubscription = this.logsRxWebsocketService.watch(`/topic/logs/${triggerKey.name}`)
.pipe(map((msg: any) => JSON.parse(msg.body))) .pipe(map((msg: any) => JSON.parse(msg.body)))
.subscribe(this._showNewLog, (err) => { .subscribe(logRecord => this.ngZone.run(() => this._showNewLog(logRecord)), (err) => {
console.log(err); console.log(err);
// TODO in case of 401 // TODO in case of 401
// this.apiService.get('/quartz-manager/session/refresh'); // this.apiService.get('/quartz-manager/session/refresh');
}); });

View File

@@ -5,13 +5,17 @@ import {jest} from '@jest/globals';
describe('ProgressPanelComponent', () => { describe('ProgressPanelComponent', () => {
const ngZone = {run: jest.fn((fn: () => void) => fn())};
beforeEach(() => ngZone.run.mockClear());
it('should subscribe to the selected trigger progress topic', () => { it('should subscribe to the selected trigger progress topic', () => {
jest.useFakeTimers(); jest.useFakeTimers();
const messages = new Subject<any>(); const messages = new Subject<any>();
const progressRxWebsocketService = { const progressRxWebsocketService = {
watch: jest.fn(() => messages.asObservable()) watch: jest.fn(() => messages.asObservable())
}; };
const component = new ProgressPanelComponent(progressRxWebsocketService as any); const component = new ProgressPanelComponent(progressRxWebsocketService as any, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
@@ -20,6 +24,7 @@ describe('ProgressPanelComponent', () => {
messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})}); messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})});
jest.runOnlyPendingTimers(); jest.runOnlyPendingTimers();
expect(ngZone.run).toHaveBeenCalled();
expect(component.progress.percentage).toEqual(75); expect(component.progress.percentage).toEqual(75);
expect(component.percentageStr).toEqual('75%'); expect(component.percentageStr).toEqual('75%');
expect(component.progressUpdated).toBeTruthy(); expect(component.progressUpdated).toBeTruthy();
@@ -34,7 +39,7 @@ describe('ProgressPanelComponent', () => {
.mockReturnValueOnce(firstMessages.asObservable()) .mockReturnValueOnce(firstMessages.asObservable())
.mockReturnValueOnce(secondMessages.asObservable()) .mockReturnValueOnce(secondMessages.asObservable())
}; };
const component = new ProgressPanelComponent(progressRxWebsocketService as any); const component = new ProgressPanelComponent(progressRxWebsocketService as any, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
const firstSubscription = component.topicSubscription; const firstSubscription = component.topicSubscription;
@@ -55,7 +60,7 @@ describe('ProgressPanelComponent', () => {
.mockReturnValueOnce(secondMessages.asObservable()) .mockReturnValueOnce(secondMessages.asObservable())
.mockReturnValueOnce(firstMessages.asObservable()) .mockReturnValueOnce(firstMessages.asObservable())
}; };
const component = new ProgressPanelComponent(progressRxWebsocketService as any); const component = new ProgressPanelComponent(progressRxWebsocketService as any, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
firstMessages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})}); firstMessages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})});
@@ -78,7 +83,7 @@ describe('ProgressPanelComponent', () => {
const progressRxWebsocketService = { const progressRxWebsocketService = {
watch: jest.fn(() => messages.asObservable()) watch: jest.fn(() => messages.asObservable())
}; };
const component = new ProgressPanelComponent(progressRxWebsocketService as any); const component = new ProgressPanelComponent(progressRxWebsocketService as any, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null); component.triggerKey = new TriggerKey('trigger-1', null);
messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})}); messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})});
@@ -94,7 +99,7 @@ describe('ProgressPanelComponent', () => {
const progressRxWebsocketService = { const progressRxWebsocketService = {
watch: jest.fn() watch: jest.fn()
}; };
const component = new ProgressPanelComponent(progressRxWebsocketService as any); const component = new ProgressPanelComponent(progressRxWebsocketService as any, ngZone as any);
expect(() => component.ngOnDestroy()).not.toThrow(); expect(() => component.ngOnDestroy()).not.toThrow();
}); });

View File

@@ -1,4 +1,4 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core' import {Component, Input, NgZone, OnDestroy, OnInit} from '@angular/core'
import TriggerFiredBundle from '../../model/trigger-fired-bundle.model'; import TriggerFiredBundle from '../../model/trigger-fired-bundle.model';
import {TriggerKey} from '../../model/triggerKey.model'; import {TriggerKey} from '../../model/triggerKey.model';
import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.service'; import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.service';
@@ -18,9 +18,10 @@ export class ProgressPanelComponent implements OnInit, OnDestroy {
topicSubscription; topicSubscription;
private selectedTriggerKey: TriggerKey; private selectedTriggerKey: TriggerKey;
constructor( constructor(
private progressRxWebsocketService: ProgressRxWebsocketService private progressRxWebsocketService: ProgressRxWebsocketService,
) { } private ngZone: NgZone
) { }
@Input() @Input()
set triggerKey(triggerKey: TriggerKey) { set triggerKey(triggerKey: TriggerKey) {
@@ -44,8 +45,8 @@ export class ProgressPanelComponent implements OnInit, OnDestroy {
this._unsubscribeFromTopic(); this._unsubscribeFromTopic();
this.topicSubscription = this.progressRxWebsocketService.watch(`/topic/progress/${triggerKey.name}`) this.topicSubscription = this.progressRxWebsocketService.watch(`/topic/progress/${triggerKey.name}`)
.pipe(map((msg: any) => JSON.parse(msg.body))) .pipe(map((msg: any) => JSON.parse(msg.body)))
.subscribe(this.onNewProgressMsg, (err) => { .subscribe(progress => this.ngZone.run(() => this.onNewProgressMsg(progress)), (err) => {
console.log(err); console.log(err);
// TODO in case of 401 // TODO in case of 401
// this.apiService.get('/quartz-manager/session/refresh'); // this.apiService.get('/quartz-manager/session/refresh');
}); });

View File

@@ -161,7 +161,7 @@
</div> </div>
<div fxFlex="1 1 auto" style="text-align: center" *ngIf="!simpleTriggerReactiveForm.enabled"> <div fxFlex="1 1 auto" style="text-align: center" *ngIf="!simpleTriggerReactiveForm.enabled">
<button mat-raised-button type="button" <button mat-raised-button type="button"
(click)="simpleTriggerReactiveForm.enable();simpleTriggerReactiveForm.controls['triggerName'].disable();"> (click)="openTriggerForm();simpleTriggerReactiveForm.controls['triggerName'].disable();">
Reschedule Reschedule
</button> </button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing';
import {MatCardModule} from '@angular/material/card'; import {MatCardModule} from '@angular/material/card';
import {SimpleTriggerConfigComponent} from './simple-trigger-config.component'; import {SimpleTriggerConfigComponent} from './simple-trigger-config.component';
import {ApiService, ConfigService, CONTEXT_PATH, SchedulerService} from '../../services'; import {ApiService, ConfigService, CONTEXT_PATH, SchedulerService} from '../../services';
@@ -124,7 +124,7 @@ describe('SimpleTriggerConfig', () => {
openFormAndFillAllMandatoryFields(); openFormAndFillAllMandatoryFields();
}); });
it('should emit an event when a new trigger is submitted', () => { it('should emit an event when a new trigger is submitted', fakeAsync(() => {
const componentDe: DebugElement = fixture.debugElement; const componentDe: DebugElement = fixture.debugElement;
const mockTrigger = new Trigger(); const mockTrigger = new Trigger();
mockTrigger.triggerKeyDTO = new TriggerKey(testTriggerName, null); mockTrigger.triggerKeyDTO = new TriggerKey(testTriggerName, null);
@@ -143,14 +143,18 @@ describe('SimpleTriggerConfig', () => {
let actualNewTrigger; let actualNewTrigger;
component.onNewTrigger.subscribe(simpleTrigger => actualNewTrigger = simpleTrigger); component.onNewTrigger.subscribe(simpleTrigger => actualNewTrigger = simpleTrigger);
let submittedTriggerKey: TriggerKey;
component.onTriggerSubmitting.subscribe(triggerKey => submittedTriggerKey = triggerKey);
submitButton.nativeElement.click(); submitButton.nativeElement.click();
expect(submittedTriggerKey).toEqual(new TriggerKey(testTriggerName, null));
flush();
const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`); const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`);
postSimpleTriggerReq.flush(mockTrigger); postSimpleTriggerReq.flush(mockTrigger);
expect(actualNewTrigger).toEqual(mockTrigger); expect(actualNewTrigger).toEqual(mockTrigger);
}); }));
it('should not emit an event when an existing trigger is edited', () => { it('should not emit an event when an existing trigger is edited', () => {
const mockTriggerKey = new TriggerKey(testTriggerName, null); const mockTriggerKey = new TriggerKey(testTriggerName, null);
@@ -247,7 +251,7 @@ describe('SimpleTriggerConfig', () => {
expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName); expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName);
component.triggerKey = null; component.openNewTriggerForm();
expect(component.simpleTriggerReactiveForm.value.triggerName).toBeNull(); expect(component.simpleTriggerReactiveForm.value.triggerName).toBeNull();
expect(component.simpleTriggerReactiveForm.value.jobClass).toBeNull(); expect(component.simpleTriggerReactiveForm.value.jobClass).toBeNull();
@@ -255,6 +259,16 @@ describe('SimpleTriggerConfig', () => {
}); });
it('should not emit form open changes while applying a null trigger input', () => {
let formOpenChangeEmitted = false;
component.triggerFormOpenChange.subscribe(() => formOpenChangeEmitted = true);
component.triggerKey = null;
expect(formOpenChangeEmitted).toBeFalsy();
expect(component.shouldShowTheTriggerCardContent()).toBeFalsy();
});
it('should display the warning if there are no eligible jobs', () => { it('should display the warning if there are no eligible jobs', () => {
fixture.detectChanges(); fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`); const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`);

View File

@@ -42,14 +42,15 @@ export class SimpleTriggerConfigComponent implements OnInit {
private jobs: Array<String>; private jobs: Array<String>;
enabledTriggerForm = false;
@Output() @Output()
onNewTrigger = new EventEmitter<SimpleTrigger>(); onNewTrigger = new EventEmitter<SimpleTrigger>();
@Output() @Output()
triggerFormOpenChange = new EventEmitter<boolean>(); triggerFormOpenChange = new EventEmitter<boolean>();
@Output()
onTriggerSubmitting = new EventEmitter<TriggerKey>();
constructor( constructor(
private formBuilder: UntypedFormBuilder, private formBuilder: UntypedFormBuilder,
private schedulerService: SchedulerService, private schedulerService: SchedulerService,
@@ -58,36 +59,33 @@ export class SimpleTriggerConfigComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.simpleTriggerReactiveForm.disable();
this.fetchJobs(); this.fetchJobs();
} }
openTriggerForm() {
this.simpleTriggerReactiveForm.enable();
}
private fetchJobs() { private fetchJobs() {
this.jobService.fetchJobs().subscribe(jobs => this.jobs = jobs); this.jobService.fetchJobs().subscribe(jobs => this.jobs = jobs);
} }
openTriggerForm() { openTriggerForm() {
this.enabledTriggerForm = true; this.simpleTriggerReactiveForm.enable();
this.triggerFormOpenChange.emit(this.enabledTriggerForm); this.triggerFormOpenChange.emit(true);
} }
private closeTriggerForm() { private closeTriggerForm() {
this.enabledTriggerForm = false; this.simpleTriggerReactiveForm.disable();
this.triggerFormOpenChange.emit(this.enabledTriggerForm); this.triggerFormOpenChange.emit(false);
} }
@Input() @Input()
set triggerKey(triggerKey: TriggerKey) { set triggerKey(triggerKey: TriggerKey) {
if (!triggerKey) { if (!triggerKey) {
this.openNewTriggerForm(); return;
} else if (!this.selectedTriggerKey || this.selectedTriggerKey.name !== triggerKey.name) { } else if (!this.selectedTriggerKey || this.selectedTriggerKey.name !== triggerKey.name) {
this._resetTheTrigger(); this._resetTheTrigger();
this.selectedTriggerKey = {...triggerKey} as TriggerKey; this.selectedTriggerKey = {...triggerKey} as TriggerKey;
this.fetchSelectedTrigger(); this.fetchSelectedTrigger();
this.closeTriggerForm(); this.simpleTriggerReactiveForm.disable();
} }
} }
@@ -133,6 +131,17 @@ export class SimpleTriggerConfigComponent implements OnInit {
this.schedulerService.updateSimpleTriggerConfig : this.schedulerService.saveSimpleTriggerConfig; this.schedulerService.updateSimpleTriggerConfig : this.schedulerService.saveSimpleTriggerConfig;
const simpleTriggerCommand = this._fromReactiveFormToCommand(); const simpleTriggerCommand = this._fromReactiveFormToCommand();
if (!this.trigger) {
this.onTriggerSubmitting.emit(new TriggerKey(simpleTriggerCommand.triggerName, null));
setTimeout(() => this.submitTriggerConfig(schedulerServiceCall, simpleTriggerCommand));
return;
}
this.submitTriggerConfig(schedulerServiceCall, simpleTriggerCommand);
}
private submitTriggerConfig(schedulerServiceCall, simpleTriggerCommand: SimpleTriggerCommand) {
this.triggerLoading = true; this.triggerLoading = true;
schedulerServiceCall(simpleTriggerCommand) schedulerServiceCall(simpleTriggerCommand)
.subscribe((retTrigger: SimpleTrigger) => { .subscribe((retTrigger: SimpleTrigger) => {
@@ -153,9 +162,8 @@ export class SimpleTriggerConfigComponent implements OnInit {
} }
this.triggerLoading = false; this.triggerLoading = false;
}, () => { }, () => {
this.triggerLoading = false this.triggerLoading = false;
}); });
} }
private _triggerPeriodValidator(control: AbstractControl): ValidationErrors | null { private _triggerPeriodValidator(control: AbstractControl): ValidationErrors | null {

View File

@@ -18,11 +18,12 @@
<div fxLayout="row" fxFill> <div fxLayout="row" fxFill>
<div fxLayout="column" fxFill> <div fxLayout="column" fxFill>
<qrzmng-simple-trigger-config <qrzmng-simple-trigger-config
fxFill fxFill
[triggerKey]="selectedTriggerKey" [triggerKey]="selectedTriggerKey"
(triggerFormOpenChange)="setNewTriggerFormOpened($event)" (triggerFormOpenChange)="setNewTriggerFormOpened($event)"
(onNewTrigger)="onNewTriggerCreated($event)"> (onTriggerSubmitting)="monitorTrigger($event)"
</qrzmng-simple-trigger-config> (onNewTrigger)="onNewTriggerCreated($event)">
</qrzmng-simple-trigger-config>
</div> </div>
</div> </div>
</div> </div>
@@ -30,16 +31,16 @@
<div class="flex-1"> <div class="flex-1">
<div class="h-100 min-h-100 flex flex-column gap-6"> <div class="h-100 min-h-100 flex flex-column gap-6">
<div class="flex flex-column" > <div class="flex flex-column" >
<progress-panel class="flex-1" <progress-panel class="flex-1"
[triggerKey]=selectedTriggerKey [triggerKey]=monitoredTriggerKey
> >
</progress-panel> </progress-panel>
</div> </div>
<div class="flex flex-column flex-1" style="max-height: calc(100% - 136px); min-height: calc(100% - 210px);"> <div class="flex flex-column flex-1" style="max-height: calc(100% - 136px); min-height: calc(100% - 210px);">
<logs-panel class="flex flex-1 h-100 max-h-100" <logs-panel class="flex flex-1 h-100 max-h-100"
[triggerKey]=selectedTriggerKey [triggerKey]=monitoredTriggerKey
> >
</logs-panel> </logs-panel>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -20,12 +20,15 @@ export class ManagerComponent implements OnInit {
selectedTriggerKey: TriggerKey; selectedTriggerKey: TriggerKey;
monitoredTriggerKey: TriggerKey;
constructor() {} constructor() {}
ngOnInit() {} ngOnInit() {}
onNewTriggerRequested() { onNewTriggerRequested() {
this.selectedTriggerKey = null; this.selectedTriggerKey = null;
this.monitoredTriggerKey = null;
this.newTriggerFormOpened = true; this.newTriggerFormOpened = true;
if (this.triggerConfigComponent) { if (this.triggerConfigComponent) {
this.triggerConfigComponent.openNewTriggerForm(); this.triggerConfigComponent.openNewTriggerForm();
@@ -39,9 +42,14 @@ export class ManagerComponent implements OnInit {
setSelectedTrigger(triggerKey: TriggerKey) { setSelectedTrigger(triggerKey: TriggerKey) {
this.selectedTriggerKey = triggerKey; this.selectedTriggerKey = triggerKey;
this.monitoredTriggerKey = triggerKey;
this.newTriggerFormOpened = false; this.newTriggerFormOpened = false;
} }
monitorTrigger(triggerKey: TriggerKey) {
this.monitoredTriggerKey = triggerKey;
}
setNewTriggerFormOpened(opened: boolean) { setNewTriggerFormOpened(opened: boolean) {
this.newTriggerFormOpened = opened; this.newTriggerFormOpened = opened;
} }