Compare commits

..

17 Commits

Author SHA1 Message Date
Fabio Formosa
068b0eed34 #103 fixed a test 2026-05-06 23:43:35 +02:00
Fabio Formosa
b2f9692815 #103 fixed a test 2026-05-06 23:41:08 +02:00
Fabio Formosa
5fc6c56409 Merge branch 'develop' into feature/#103_multiple_triggers
# Conflicts:
#	quartz-manager-parent/.gitignore
2026-05-06 23:26:40 +02:00
Fabio Formosa
d7a78c57ae #103 minor enhancements 2026-05-06 23:13:44 +02:00
Fabio Formosa
31658416f5 #103 final step into multi-trigger feature 2026-05-06 01:22:30 +02:00
Fabio Formosa
1d81e88684 Merge remote-tracking branch 'origin/master' into develop 2026-04-27 23:13:05 +02:00
Fabio Formosa
f55a58b100 Add workflow_dispatch trigger to sonar-java.yml
added the workflow_dispatch to the GitHub Action
2026-04-23 23:26:20 +02:00
Fabio Formosa
902542e480 Update sonar-java.yml
added the workflow_dispatch trigger for the sonar action
2026-04-23 22:39:34 +02:00
Fabio Formosa
c198d32bd5 bump to version 4.0.11-SNAPSHOT 2025-05-23 23:06:34 +02:00
Fabio Formosa
412e455907 #103 step into accomodating the new trigger logic 2024-02-02 22:58:56 +01:00
Fabio Formosa
727a11fcea #103 fixed a test 2022-12-21 00:03:39 +01:00
Fabio Formosa
7106dc0fbb #103 clean up 2022-12-21 00:00:29 +01:00
Fabio Formosa
a2c8ecb227 #103 migrated the progress websocket to rx-stomp 2022-12-20 23:59:30 +01:00
Fabio Formosa
e4c771e364 #103 migrated the progress websocket to rx-stomp 2022-12-20 23:58:51 +01:00
Fabio Formosa
261dd8b624 #103 appended the trigger key to the websocket topic 2022-12-20 20:51:10 +01:00
Fabio Formosa
ac63576704 #103 migrated the logs websocket to rx-stomp 2022-12-18 11:58:24 +01:00
Fabio Formosa
a3b92443c4 #103 enabled a dev profile in the angular.json 2022-12-18 11:53:32 +01:00
51 changed files with 953 additions and 451 deletions

View File

@@ -7,6 +7,7 @@ on:
# paths: [ 'quartz-manager-parent/**' ]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
jobs:
build:
name: Build and analyze

View File

@@ -0,0 +1,19 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"extends": [
"plugin:sonarjs/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json",
"sourceType": "module"
},
"plugins": [
"sonarjs"
],
"root": true
}

View File

@@ -3,7 +3,7 @@
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular-spring-starter": {
"quartz-manager-ui": {
"root": "",
"prefix": "qrzmng",
"sourceRoot": "src",
@@ -19,7 +19,7 @@
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"allowedCommonJsDependencies": [
"stompjs", "sockjs-client", "moment"
"stompjs", "sockjs-client", "moment", "angular2-uuid"
],
"assets": [
"src/assets",
@@ -31,6 +31,14 @@
"scripts": []
},
"configurations": {
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"production": {
"budgets": [
{
@@ -58,18 +66,18 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "angular-spring-starter:build"
"browserTarget": "quartz-manager-ui:build:development"
},
"configurations": {
"production": {
"browserTarget": "angular-spring-starter:build:production"
"browserTarget": "quartz-manager-ui:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "angular-spring-starter:build"
"browserTarget": "quartz-manager-ui:build"
}
},
"lint": {
@@ -81,7 +89,7 @@
}
}
},
"angular-spring-starter-e2e": {
"quartz-manager-ui-e2e": {
"root": "e2e",
"sourceRoot": "e2e",
"projectType": "application",
@@ -90,7 +98,7 @@
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "./protractor.conf.js",
"devServerTarget": "angular-spring-starter:serve"
"devServerTarget": "quartz-manager-ui:serve"
}
},
"lint": {

View File

@@ -27,7 +27,7 @@
"@fortawesome/fontawesome": "^1.1.4",
"@fortawesome/fontawesome-free-regular": "^5.0.8",
"@fortawesome/fontawesome-free-solid": "^5.0.8",
"@stomp/ng2-stompjs": "^0.6.3",
"@stomp/rx-stomp": "1.2.0",
"core-js": "2.5.1",
"hammerjs": "2.0.8",
"moment": "^2.29.1",
@@ -62,6 +62,7 @@
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-sonarjs": "^0.16.0",
"jasmine-core": "~4.5.0",
"jasmine-spec-reporter": "~7.0.0",
"jest": "28.1.3",
@@ -4229,19 +4230,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stomp/ng2-stompjs": {
"version": "0.6.4",
"license": "MIT",
"node_modules/@stomp/rx-stomp": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@stomp/rx-stomp/-/rx-stomp-1.2.0.tgz",
"integrity": "sha512-QLzPe3q0EwLB+cVWdUFEO4z5tyR+kPnXJANKN2UvB7Spz/oViHF959cydmXdQWaK7NHp86VO54TgFfXbHVnSLg==",
"dependencies": {
"@stomp/stompjs": "^4.0.0 >=4.0.2"
"@stomp/stompjs": "^6.0.0 >=6.1.1",
"angular2-uuid": "^1.1.1"
}
},
"node_modules/@stomp/stompjs": {
"version": "4.0.8",
"license": "Apache-2.0",
"optionalDependencies": {
"websocket": "^1.0.24"
}
"node_modules/@stomp/rx-stomp/node_modules/@stomp/stompjs": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-6.1.2.tgz",
"integrity": "sha512-FHDTrIFM5Ospi4L3Xhj6v2+NzCVAeNDcBe95YjUWhWiRMrBF6uN3I7AUOlRgT6jU/2WQvvYK8ZaIxFfxFp+uHQ=="
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
@@ -5419,6 +5420,11 @@
"ajv": "^8.8.2"
}
},
"node_modules/angular2-uuid": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/angular2-uuid/-/angular2-uuid-1.1.1.tgz",
"integrity": "sha512-6AXPyii9q8KBFGagybLNVmdGJLPcVZAhmv3odNGSJIA18LuJ3xOe6uN9GvjlQsGfdmYeuxlsGnFEUu7gPhkc+g=="
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -8599,6 +8605,18 @@
}
}
},
"node_modules/eslint-plugin-sonarjs": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.16.0.tgz",
"integrity": "sha512-al8ojAzcQW8Eu0tWn841ldhPpPcjrJ59TzzTfAVWR45bWvdAASCmrGl8vK0MWHyKVDdC0i17IGbtQQ1KgxLlVA==",
"dev": true,
"engines": {
"node": ">=14"
},
"peerDependencies": {
"eslint": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/eslint-scope": {
"version": "5.1.1",
"dev": true,
@@ -22763,16 +22781,20 @@
"version": "3.1.0",
"dev": true
},
"@stomp/ng2-stompjs": {
"version": "0.6.4",
"@stomp/rx-stomp": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@stomp/rx-stomp/-/rx-stomp-1.2.0.tgz",
"integrity": "sha512-QLzPe3q0EwLB+cVWdUFEO4z5tyR+kPnXJANKN2UvB7Spz/oViHF959cydmXdQWaK7NHp86VO54TgFfXbHVnSLg==",
"requires": {
"@stomp/stompjs": "^4.0.0 >=4.0.2"
}
},
"@stomp/stompjs": {
"version": "4.0.8",
"requires": {
"websocket": "^1.0.24"
"@stomp/stompjs": "^6.0.0 >=6.1.1",
"angular2-uuid": "^1.1.1"
},
"dependencies": {
"@stomp/stompjs": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-6.1.2.tgz",
"integrity": "sha512-FHDTrIFM5Ospi4L3Xhj6v2+NzCVAeNDcBe95YjUWhWiRMrBF6uN3I7AUOlRgT6jU/2WQvvYK8ZaIxFfxFp+uHQ=="
}
}
},
"@tootallnate/once": {
@@ -23670,6 +23692,11 @@
"fast-deep-equal": "^3.1.3"
}
},
"angular2-uuid": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/angular2-uuid/-/angular2-uuid-1.1.1.tgz",
"integrity": "sha512-6AXPyii9q8KBFGagybLNVmdGJLPcVZAhmv3odNGSJIA18LuJ3xOe6uN9GvjlQsGfdmYeuxlsGnFEUu7gPhkc+g=="
},
"ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -26034,6 +26061,13 @@
"prettier-linter-helpers": "^1.0.0"
}
},
"eslint-plugin-sonarjs": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.16.0.tgz",
"integrity": "sha512-al8ojAzcQW8Eu0tWn841ldhPpPcjrJ59TzzTfAVWR45bWvdAASCmrGl8vK0MWHyKVDdC0i17IGbtQQ1KgxLlVA==",
"dev": true,
"requires": {}
},
"eslint-scope": {
"version": "5.1.1",
"dev": true,

View File

@@ -8,6 +8,8 @@
"build": "ng build --configuration production",
"test": "jest",
"lint": "ng lint",
"lint:sonar": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\"",
"lint:sonar:fix": "eslint --no-eslintrc -c .eslintrc.sonar.json \"src/**/*.ts\" --fix",
"e2e": "ng e2e"
},
"private": true,
@@ -30,7 +32,7 @@
"@fortawesome/fontawesome": "^1.1.4",
"@fortawesome/fontawesome-free-regular": "^5.0.8",
"@fortawesome/fontawesome-free-solid": "^5.0.8",
"@stomp/ng2-stompjs": "^0.6.3",
"@stomp/rx-stomp": "1.2.0",
"core-js": "2.5.1",
"hammerjs": "2.0.8",
"moment": "^2.29.1",
@@ -65,6 +67,7 @@
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-sonarjs": "^0.16.0",
"jasmine-core": "~4.5.0",
"jasmine-spec-reporter": "~7.0.0",
"jest": "28.1.3",

View File

@@ -13,8 +13,8 @@ import {
} from '@angular/platform-browser-dynamic/testing';
// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare var __karma__: any;
declare var require: any;
declare let __karma__: any;
declare let require: any;
// Prevent Karma from running prematurely.
__karma__.loaded = function () {};

View File

@@ -43,7 +43,8 @@ import {
SchedulerControlComponent,
LogsPanelComponent,
ProgressPanelComponent,
TriggerListComponent
TriggerListComponent,
SimpleTriggerConfigComponent
} from './components';
import {
@@ -52,14 +53,13 @@ import {
UserService,
SchedulerService,
ConfigService,
ProgressWebsocketService,
LogsWebsocketService,
getHtmlBaseUrl,
LogsRxWebsocketService,
ProgressRxWebsocketService,
TriggerService
} from './services';
import { ForbiddenComponent } from './views/forbidden/forbidden.component';
import { APP_BASE_HREF } from '@angular/common';
import {SimpleTriggerConfigComponent} from './components/simple-trigger-config';
import JobService from './services/job.service';
import {GenericErrorComponent} from './views/error/genericError.component';
@@ -67,31 +67,6 @@ export function initUserFactory(userService: UserService) {
return () => userService.fetchLoggedUser();
}
// const stompConfig: StompConfig = {
// // Which server?
// url: 'ws://localhost:8080/quartz-manager/progress',
// // Headers
// // Typical keys: login, passcode, host
// headers: {
// login: 'admin',
// passcode: 'admin'
// },
// // How often to heartbeat?
// // Interval in milliseconds, set to 0 to disable
// heartbeat_in: 0, // Typical value 0 - disabled
// heartbeat_out: 20000, // Typical value 20000 - every 20 seconds
// // Wait in milliseconds before attempting auto reconnect
// // Set to 0 to disable
// // Typical value 5000 (5 seconds)
// reconnect_delay: 5000,
// // Will log diagnostics on console
// debug: true
// };
export function jwtOptionsFactory(apiService: ApiService) {
return {
tokenGetter: () => {
@@ -168,19 +143,13 @@ export function jwtOptionsFactory(apiService: ApiService) {
SchedulerService,
JobService,
TriggerService,
ProgressWebsocketService,
LogsWebsocketService,
ProgressRxWebsocketService,
LogsRxWebsocketService,
AuthService,
ApiService,
UserService,
ConfigService,
MatIconRegistry
// StompService,
// ServerSocket
// {
// provide: StompConfig,
// useValue: stompConfig
// }
],
bootstrap: [AppComponent]
})

View File

@@ -5,3 +5,4 @@ export * from './logs-panel';
export * from './scheduler-control';
export * from './progress-panel';
export * from './trigger-list';
export * from './simple-trigger-config';

View File

@@ -3,12 +3,16 @@
<mat-card-subtitle><b>JOB LOGS</b></mat-card-subtitle>
</mat-card-header>
<mat-card-content style="position: relative; height: calc(100% - 3em);">
<div *ngIf="!logs || logs.length == 0" fxLayout="row" fxFlexAlign="center stretch" style="text-align: center">
<div fxFill style="height: 100%;">
<img src="assets/image/logs.svg" alt="no logs" width="320" style="margin-top: 6em;" />
</div>
</div>
<div id="logs" style="overflow-y: auto; position: absolute; left: 0; right: 0; top: 0; bottom: 0; overflow: auto;">
<div *ngIf="!selectedTriggerName && (!logs || logs.length == 0)" fxLayout="row" fxFlexAlign="center stretch" style="text-align: center">
<div fxFill style="height: 100%;">
<img src="assets/image/logs.svg" alt="no logs" width="320" style="margin-top: 6em;" />
</div>
</div>
<div *ngIf="isWaitingForLogs()" class="waitingLogs" fxLayout="column" fxLayoutAlign="center center" fxLayoutGap="12px">
<mat-spinner diameter="36"></mat-spinner>
<div>Waiting for logs from {{selectedTriggerName}}...</div>
</div>
<div id="logs" style="overflow-y: auto; position: absolute; left: 0; right: 0; top: 0; bottom: 0; overflow: auto;">
<div
*ngFor = "let log of logs; let first = first" fxLayout="row" fxLayout.xs="column" fxLayoutAlign="start" fxLayoutGap="10px">
<div fxFlex="0 1 300px">

View File

@@ -9,11 +9,18 @@
color: gold;
}
#logs{
font-size: 1em;
}
/* ===== Scrollbar CSS ===== */
#logs{
font-size: 1em;
}
.waitingLogs {
color: #6b7280;
height: 100%;
min-height: 180px;
text-align: center;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: auto;

View File

@@ -0,0 +1,113 @@
import {Subject} from 'rxjs';
import {LogsPanelComponent} from './logs-panel.component';
import {TriggerKey} from '../../model/triggerKey.model';
import {jest} from '@jest/globals';
describe('LogsPanelComponent', () => {
it('should subscribe to the selected trigger logs topic', () => {
const messages = new Subject<any>();
const logsRxWebsocketService = {
watch: jest.fn(() => messages.asObservable())
};
const component = new LogsPanelComponent(logsRxWebsocketService as any, null);
component.triggerKey = new TriggerKey('trigger-1', null);
expect(logsRxWebsocketService.watch).toHaveBeenCalledWith('/topic/logs/trigger-1');
expect(component.selectedTriggerName).toEqual('trigger-1');
expect(component.isWaitingForLogs()).toBeTruthy();
const logRecord = {
date: new Date(),
type: 'INFO',
message: 'job completed',
threadName: 'worker-1'
};
messages.next({body: JSON.stringify(logRecord)});
expect(component.logs[0]).toEqual({
time: logRecord.date.toISOString(),
type: 'INFO',
msg: 'job completed',
threadName: 'worker-1'
});
expect(component.isWaitingForLogs()).toBeFalsy();
});
it('should unsubscribe from the previous topic when the trigger changes', () => {
const firstMessages = new Subject<any>();
const secondMessages = new Subject<any>();
const logsRxWebsocketService = {
watch: jest.fn()
.mockReturnValueOnce(firstMessages.asObservable())
.mockReturnValueOnce(secondMessages.asObservable())
};
const component = new LogsPanelComponent(logsRxWebsocketService as any, null);
component.triggerKey = new TriggerKey('trigger-1', null);
const firstSubscription = component.topicSubscription;
jest.spyOn(firstSubscription, 'unsubscribe');
component.triggerKey = new TriggerKey('trigger-2', null);
expect(firstSubscription.unsubscribe).toHaveBeenCalled();
expect(logsRxWebsocketService.watch).toHaveBeenCalledWith('/topic/logs/trigger-2');
});
it('should clear logs when the trigger changes', () => {
const firstMessages = new Subject<any>();
const secondMessages = new Subject<any>();
const logsRxWebsocketService = {
watch: jest.fn()
.mockReturnValueOnce(firstMessages.asObservable())
.mockReturnValueOnce(secondMessages.asObservable())
.mockReturnValueOnce(firstMessages.asObservable())
};
const component = new LogsPanelComponent(logsRxWebsocketService as any, null);
component.triggerKey = new TriggerKey('trigger-1', null);
firstMessages.next({body: JSON.stringify({date: new Date(), type: 'INFO', message: 'first log', threadName: 'worker-1'})});
expect(component.logs.length).toEqual(1);
component.triggerKey = new TriggerKey('trigger-2', null);
expect(component.logs).toEqual([]);
expect(component.selectedTriggerName).toEqual('trigger-2');
expect(component.isWaitingForLogs()).toBeTruthy();
secondMessages.next({body: JSON.stringify({date: new Date(), type: 'INFO', message: 'second log', threadName: 'worker-2'})});
expect(component.logs.length).toEqual(1);
component.triggerKey = new TriggerKey('trigger-1', null);
expect(component.logs).toEqual([]);
expect(component.selectedTriggerName).toEqual('trigger-1');
expect(component.isWaitingForLogs()).toBeTruthy();
});
it('should clear logs when no trigger is selected', () => {
const messages = new Subject<any>();
const logsRxWebsocketService = {
watch: jest.fn(() => messages.asObservable())
};
const component = new LogsPanelComponent(logsRxWebsocketService as any, null);
component.triggerKey = new TriggerKey('trigger-1', null);
messages.next({body: JSON.stringify({date: new Date(), type: 'INFO', message: 'first log', threadName: 'worker-1'})});
component.triggerKey = null;
expect(component.logs).toEqual([]);
expect(component.selectedTriggerName).toBeNull();
expect(component.isWaitingForLogs()).toBeFalsy();
});
it('should ignore destroy when no topic was selected', () => {
const logsRxWebsocketService = {
watch: jest.fn()
};
const component = new LogsPanelComponent(logsRxWebsocketService as any, null);
expect(() => component.ngOnDestroy()).not.toThrow();
});
});

View File

@@ -1,42 +1,84 @@
import {Component, OnInit, Input, Output, EventEmitter} from '@angular/core';
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {ApiService} from '../../services';
import {LogsRxWebsocketService} from '../../services/logs.rx-websocket.service';
import {map} from 'rxjs/operators';
import {TriggerKey} from '../../model/triggerKey.model';
import {LogsWebsocketService, ApiService} from '../../services';
import {Observable} from 'rxjs';
@Component({
selector: 'logs-panel',
templateUrl: './logs-panel.component.html',
styleUrls: ['./logs-panel.component.scss']
})
export class LogsPanelComponent implements OnInit {
export class LogsPanelComponent implements OnInit, OnDestroy {
MAX_LOGS = 30;
logs = new Array();
logs = new Array();
selectedTriggerName: string;
topicSubscription;
private selectedTriggerKey: TriggerKey;
constructor(
private logsWebsocketService: LogsWebsocketService,
private logsRxWebsocketService: LogsRxWebsocketService,
private apiService: ApiService
) {
}
@Input()
set triggerKey(triggerKey: TriggerKey) {
if (!triggerKey || !triggerKey.name) {
this._unsubscribeFromTopic();
this.selectedTriggerKey = null;
this.selectedTriggerName = null;
this._resetLogs();
return;
}
if (this.selectedTriggerKey?.name === triggerKey.name) {
return;
}
this._resetLogs();
this.selectedTriggerKey = {...triggerKey} as TriggerKey;
this.selectedTriggerName = triggerKey.name;
this._subscribeToTheTopic(this.selectedTriggerKey);
}
isWaitingForLogs = (): boolean => !!this.selectedTriggerName && (!this.logs || this.logs.length === 0);
ngOnInit() {
const obs = this.logsWebsocketService.getObservable()
obs.subscribe({
'next': this.onNewLogMsg,
'error': (err) => {
console.log(err)
}
});
}
onNewLogMsg = (receivedMsg) => {
if (receivedMsg.type === 'SUCCESS') {
this._showNewLog(receivedMsg.message);
} else if (receivedMsg.type === 'ERROR') {
this._refreshSession();
} // if websocket has been closed for session expiration, try to refresh it
};
private _subscribeToTheTopic = (triggerKey: TriggerKey) => {
this._unsubscribeFromTopic();
this.topicSubscription = this.logsRxWebsocketService.watch(`/topic/logs/${triggerKey.name}`)
.pipe(map((msg: any) => JSON.parse(msg.body)))
.subscribe(this._showNewLog, (err) => {
console.log(err);
// TODO in case of 401
// this.apiService.get('/quartz-manager/session/refresh');
});
};
ngOnDestroy() {
this._unsubscribeFromTopic();
}
private _unsubscribeFromTopic() {
if (this.topicSubscription) {
this.topicSubscription.unsubscribe();
this.topicSubscription = null;
}
}
private _resetLogs() {
this.logs = [];
}
_showNewLog = (logRecord) => {
if (this.logs.length > this.MAX_LOGS) {

View File

@@ -6,12 +6,12 @@
</div>
</div> -->
<mat-card style="padding-bottom: 0">
<mat-card style="padding-bottom: 0" [ngClass]="{'progress-updated': progressUpdated}">
<mat-card-header>
<mat-card-subtitle><b>JOB PROGRESS</b></mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div id="progressBarBox" *ngIf="progress.percentage !== -1">
<div id="progressBarBox" *ngIf="progress.percentage !== -1">
<mat-progress-bar mode="determinate" value="{{progress.percentage}}"></mat-progress-bar>
{{percentageStr}}
</div>

View File

@@ -31,3 +31,21 @@
.fireBoxContent{
text-align: center;
}
.progress-updated {
animation: progressUpdatePulse 700ms ease-out;
}
@keyframes progressUpdatePulse {
0% {
box-shadow: 0 0 0 0 rgba(63, 81, 181, 0.35);
}
45% {
box-shadow: 0 0 0 6px rgba(63, 81, 181, 0.16);
}
100% {
box-shadow: 0 0 0 0 rgba(63, 81, 181, 0);
}
}

View File

@@ -0,0 +1,102 @@
import {Subject} from 'rxjs';
import {ProgressPanelComponent} from './progress-panel.component';
import {TriggerKey} from '../../model/triggerKey.model';
import {jest} from '@jest/globals';
describe('ProgressPanelComponent', () => {
it('should subscribe to the selected trigger progress topic', () => {
jest.useFakeTimers();
const messages = new Subject<any>();
const progressRxWebsocketService = {
watch: jest.fn(() => messages.asObservable())
};
const component = new ProgressPanelComponent(progressRxWebsocketService as any);
component.triggerKey = new TriggerKey('trigger-1', null);
expect(progressRxWebsocketService.watch).toHaveBeenCalledWith('/topic/progress/trigger-1');
messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})});
jest.runOnlyPendingTimers();
expect(component.progress.percentage).toEqual(75);
expect(component.percentageStr).toEqual('75%');
expect(component.progressUpdated).toBeTruthy();
jest.useRealTimers();
});
it('should unsubscribe from the previous topic when the trigger changes', () => {
const firstMessages = new Subject<any>();
const secondMessages = new Subject<any>();
const progressRxWebsocketService = {
watch: jest.fn()
.mockReturnValueOnce(firstMessages.asObservable())
.mockReturnValueOnce(secondMessages.asObservable())
};
const component = new ProgressPanelComponent(progressRxWebsocketService as any);
component.triggerKey = new TriggerKey('trigger-1', null);
const firstSubscription = component.topicSubscription;
jest.spyOn(firstSubscription, 'unsubscribe');
component.triggerKey = new TriggerKey('trigger-2', null);
expect(firstSubscription.unsubscribe).toHaveBeenCalled();
expect(progressRxWebsocketService.watch).toHaveBeenCalledWith('/topic/progress/trigger-2');
});
it('should reset progress when the trigger changes', () => {
const firstMessages = new Subject<any>();
const secondMessages = new Subject<any>();
const progressRxWebsocketService = {
watch: jest.fn()
.mockReturnValueOnce(firstMessages.asObservable())
.mockReturnValueOnce(secondMessages.asObservable())
.mockReturnValueOnce(firstMessages.asObservable())
};
const component = new ProgressPanelComponent(progressRxWebsocketService as any);
component.triggerKey = new TriggerKey('trigger-1', null);
firstMessages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})});
expect(component.progress.percentage).toEqual(75);
component.triggerKey = new TriggerKey('trigger-2', null);
expect(component.progress.percentage).toEqual(-1);
expect(component.percentageStr).toBeNull();
expect(component.progressUpdated).toBeFalsy();
secondMessages.next({body: JSON.stringify({percentage: 20, timesTriggered: 1})});
expect(component.progress.percentage).toEqual(20);
component.triggerKey = new TriggerKey('trigger-1', null);
expect(component.progress.percentage).toEqual(-1);
});
it('should reset progress when no trigger is selected', () => {
const messages = new Subject<any>();
const progressRxWebsocketService = {
watch: jest.fn(() => messages.asObservable())
};
const component = new ProgressPanelComponent(progressRxWebsocketService as any);
component.triggerKey = new TriggerKey('trigger-1', null);
messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})});
component.triggerKey = null;
expect(component.progress.percentage).toEqual(-1);
expect(component.percentageStr).toBeNull();
expect(component.progressUpdated).toBeFalsy();
});
it('should ignore destroy when no topic was selected', () => {
const progressRxWebsocketService = {
watch: jest.fn()
};
const component = new ProgressPanelComponent(progressRxWebsocketService as any);
expect(() => component.ngOnDestroy()).not.toThrow();
});
});

View File

@@ -1,84 +1,91 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'
import {ProgressWebsocketService, QuartzManagerWebsocketMessage} from '../../services';
import { Observable } from 'rxjs';
import {Component, Input, OnDestroy, OnInit} from '@angular/core'
import TriggerFiredBundle from '../../model/trigger-fired-bundle.model';
// import {Message} from '@stomp/stompjs';
// import { Subscription } from 'rxjs/Subscription';
// import {StompService} from '@stomp/ng2-stompjs';
// import { QueueingSubject } from 'queueing-subject'
// import websocketConnect from 'rxjs-websockets'
// import 'rxjs/add/operator/share'
// import {ServerSocket} from '../../services/qz.socket.service'
import {TriggerKey} from '../../model/triggerKey.model';
import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.service';
import {map} from 'rxjs/operators';
@Component({
selector: 'progress-panel',
templateUrl: './progress-panel.component.html',
styleUrls: ['./progress-panel.component.scss']
})
export class ProgressPanelComponent implements OnInit {
progress: TriggerFiredBundle = new TriggerFiredBundle();
percentageStr: string;
// // Stream of messages
// private subscription: Subscription;
// public messages: Observable<Message>;
// // Subscription status
// public subscribed: boolean;
// // Array of historic message (bodies)
// public mq: Array<string> = [];
export class ProgressPanelComponent implements OnInit, OnDestroy {
progress: TriggerFiredBundle = ProgressPanelComponent._buildEmptyProgress();
percentageStr: string;
progressUpdated = false;
topicSubscription;
private selectedTriggerKey: TriggerKey;
constructor(
private progressWebsocketService: ProgressWebsocketService,
// private _stompService: StompService,
// private serverSocket : ServerSocket
private progressRxWebsocketService: ProgressRxWebsocketService
) { }
onNewProgressMsg = (receivedMsg: QuartzManagerWebsocketMessage) => {
if (receivedMsg.type === 'SUCCESS') {
const newStatus = receivedMsg.message;
this.progress = newStatus;
this.percentageStr = this.progress.percentage + '%';
}
}
@Input()
set triggerKey(triggerKey: TriggerKey) {
if (!triggerKey || !triggerKey.name) {
this._unsubscribeFromTopic();
this.selectedTriggerKey = null;
this._resetProgress();
return;
}
if (this.selectedTriggerKey?.name === triggerKey.name) {
return;
}
this._resetProgress();
this.selectedTriggerKey = {...triggerKey} as TriggerKey;
this._subscribeToTheTopic(this.selectedTriggerKey);
}
private _subscribeToTheTopic = (triggerKey: TriggerKey) => {
this._unsubscribeFromTopic();
this.topicSubscription = this.progressRxWebsocketService.watch(`/topic/progress/${triggerKey.name}`)
.pipe(map((msg: any) => JSON.parse(msg.body)))
.subscribe(this.onNewProgressMsg, (err) => {
console.log(err);
// TODO in case of 401
// this.apiService.get('/quartz-manager/session/refresh');
});
};
onNewProgressMsg = (receivedMsg) => {
this.progress = receivedMsg;
this.percentageStr = this.progress.percentage + '%';
this._markProgressUpdated();
}
ngOnInit() {
const obs = this.progressWebsocketService.getObservable()
obs.subscribe({
'next' : this.onNewProgressMsg,
'error' : (err) => {console.log(err)}
});
// this.subscribed = false;
// this.subscribe();
// this.serverSocket.connect()
// this.socketSubscription = this.serverSocket.messages.subscribe((message: string) => {
// console.log('received message from server: ', message)
// })
}
// public subscribe() {
// if (this.subscribed) {
// return;
// }
// // Stream of messages
// this.messages = this._stompService.subscribe('/topic/progress');
// // Subscribe a function to be run on_next message
// this.subscription = this.messages.subscribe(this.on_next);
// this.subscribed = true;
// }
// public on_next = (message: Message) => {
// this.mq.push(message.body + '\n');
// console.log(message);
// }
}
}
ngOnDestroy() {
this._unsubscribeFromTopic();
}
private _unsubscribeFromTopic() {
if (this.topicSubscription) {
this.topicSubscription.unsubscribe();
this.topicSubscription = null;
}
}
private _resetProgress() {
this.progress = ProgressPanelComponent._buildEmptyProgress();
this.percentageStr = null;
this.progressUpdated = false;
}
private _markProgressUpdated() {
this.progressUpdated = false;
setTimeout(() => this.progressUpdated = true);
}
private static _buildEmptyProgress() {
const progress = new TriggerFiredBundle();
progress.percentage = -1;
return progress;
}
}

View File

@@ -13,6 +13,12 @@ import {MatDividerModule} from '@angular/material/divider';
describe('SchedulerControlComponent', () => {
const schedulerUrl = '/quartz-manager/scheduler';
const schedulerButtonSelector = '#schedulerControllerBtn';
const schedulerName = 'test-scheduler';
const schedulerId = 'test-id';
const stoppedStatus = 'STOPPED';
let component: SchedulerControlComponent;
let fixture: ComponentFixture<SchedulerControlComponent>;
@@ -38,16 +44,16 @@ describe('SchedulerControlComponent', () => {
it('should display the play button at the beginning since the scheduler is stopped', () => {
expect(component).toBeDefined();
const getSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler');
const mockScheduler = new Scheduler('test-scheduler', 'test-id', 'STOPPED', []);
const getSchedulerReq = httpTestingController.expectOne(schedulerUrl);
const mockScheduler = new Scheduler(schedulerName, schedulerId, stoppedStatus, []);
getSchedulerReq.flush(mockScheduler);
expect(component.scheduler).toEqual(mockScheduler);
expect(component.scheduler.status).toEqual('STOPPED');
expect(component.scheduler.status).toEqual(stoppedStatus);
fixture.detectChanges();
const schedulerControlComponentDe: DebugElement = fixture.debugElement;
const schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn'));
const schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector));
expect(schedulerBtnDe).toBeTruthy();
const playIconDe = schedulerBtnDe.query(By.css('.fa-play'));
@@ -56,13 +62,13 @@ describe('SchedulerControlComponent', () => {
it('should switch the button to pause when the scheduler is started', () => {
expect(component).toBeDefined();
const getSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler');
const mockScheduler = new Scheduler('test-scheduler', 'test-id', 'STOPPED', []);
const getSchedulerReq = httpTestingController.expectOne(schedulerUrl);
const mockScheduler = new Scheduler(schedulerName, schedulerId, stoppedStatus, []);
getSchedulerReq.flush(mockScheduler);
fixture.detectChanges();
const schedulerControlComponentDe: DebugElement = fixture.debugElement;
let schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn'));
let schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector));
expect(schedulerBtnDe).toBeTruthy();
const playIconDe = schedulerBtnDe.query(By.css('.fa-play'));
expect(playIconDe).toBeTruthy();
@@ -72,7 +78,7 @@ describe('SchedulerControlComponent', () => {
startSchedulerReq.flush(null);
fixture.detectChanges();
schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn'));
schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector));
const pauseIconDe = schedulerBtnDe.query(By.css('.fa-pause'));
expect(pauseIconDe).toBeTruthy();
@@ -80,13 +86,13 @@ describe('SchedulerControlComponent', () => {
it('should switch the button to play when the scheduler is stopped', () => {
expect(component).toBeDefined();
const getSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler');
const mockScheduler = new Scheduler('test-scheduler', 'test-id', 'RUNNING', []);
const getSchedulerReq = httpTestingController.expectOne(schedulerUrl);
const mockScheduler = new Scheduler(schedulerName, schedulerId, 'RUNNING', []);
getSchedulerReq.flush(mockScheduler);
fixture.detectChanges();
const schedulerControlComponentDe: DebugElement = fixture.debugElement;
let schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn'));
let schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector));
expect(schedulerBtnDe).toBeTruthy();
const pauseIconDe = schedulerBtnDe.query(By.css('.fa-pause'));
expect(pauseIconDe).toBeTruthy();
@@ -96,7 +102,7 @@ describe('SchedulerControlComponent', () => {
startSchedulerReq.flush(null);
fixture.detectChanges();
schedulerBtnDe = schedulerControlComponentDe.query(By.css('#schedulerControllerBtn'));
schedulerBtnDe = schedulerControlComponentDe.query(By.css(schedulerButtonSelector));
const playIconDe = schedulerBtnDe.query(By.css('.fa-play'));
expect(playIconDe).toBeTruthy();

View File

@@ -23,6 +23,11 @@ import {MisfireInstruction} from '../../model/misfire-instruction.model';
describe('SimpleTriggerConfig', () => {
const submitButtonSelector = 'form button[color="primary"]';
const repeatIntervalSelector = '#repeatInterval';
const testTriggerName = 'test-trigger';
const testJobName = 'TestJob';
let component: SimpleTriggerConfigComponent;
let fixture: ComponentFixture<SimpleTriggerConfigComponent>;
@@ -91,16 +96,16 @@ describe('SimpleTriggerConfig', () => {
fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`);
getJobsReq.flush(['TestJob']);
getJobsReq.flush([testJobName]);
const componentDe: DebugElement = fixture.debugElement;
const submitButton = componentDe.query(By.css('form button[color="primary"]'));
const submitButton = componentDe.query(By.css(submitButtonSelector));
expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit');
expect(submitButton.nativeElement.getAttribute('disabled')).toEqual('');
setInputValue(componentDe, '#triggerName', 'test-trigger');
expect(component.simpleTriggerReactiveForm.controls.triggerName.value).toEqual('test-trigger');
setInputValue(componentDe, '#triggerName', testTriggerName);
expect(component.simpleTriggerReactiveForm.controls.triggerName.value).toEqual(testTriggerName);
expect(submitButton.nativeElement.getAttribute('disabled')).toEqual('');
setMatSelectValueByIndex(componentDe, '#misfireInstruction', 0);
expect(component.simpleTriggerReactiveForm.controls.misfireInstruction.value).toEqual('MISFIRE_INSTRUCTION_FIRE_NOW');
@@ -111,7 +116,7 @@ describe('SimpleTriggerConfig', () => {
setInputValue(componentDe, '#repeatCount', '1000');
expect(submitButton.nativeElement.getAttribute('disabled')).toEqual('');
setInputValue(componentDe, '#repeatInterval', '2000');
setInputValue(componentDe, repeatIntervalSelector, '2000');
expect(submitButton.nativeElement.getAttribute('disabled')).toEqual(null);
}
@@ -122,18 +127,18 @@ describe('SimpleTriggerConfig', () => {
it('should emit an event when a new trigger is submitted', () => {
const componentDe: DebugElement = fixture.debugElement;
const mockTrigger = new Trigger();
mockTrigger.triggerKeyDTO = new TriggerKey('test-trigger', null);
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: 'TestJob', description: null};
mockTrigger.triggerKeyDTO = new TriggerKey(testTriggerName, null);
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: testJobName, description: null};
mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW;
openFormAndFillAllMandatoryFields();
setInputValue(componentDe, '#repeatInterval', '2000');
setInputValue(componentDe, repeatIntervalSelector, '2000');
expect(component.simpleTriggerReactiveForm.controls.triggerRecurrence.value.repeatInterval).toEqual(2000);
setInputValue(componentDe, '#repeatCount', '100');
expect(component.simpleTriggerReactiveForm.controls.triggerRecurrence.value.repeatCount).toEqual(100);
const submitButton = componentDe.query(By.css('form button[color="primary"]'));
const submitButton = componentDe.query(By.css(submitButtonSelector));
expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit');
let actualNewTrigger;
@@ -141,28 +146,28 @@ describe('SimpleTriggerConfig', () => {
submitButton.nativeElement.click();
const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`);
const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`);
postSimpleTriggerReq.flush(mockTrigger);
expect(actualNewTrigger).toEqual(mockTrigger);
});
it('should not emit an event when an existing trigger is edited', () => {
const mockTriggerKey = new TriggerKey('test-trigger', null);
const mockTriggerKey = new TriggerKey(testTriggerName, null);
component.triggerKey = mockTriggerKey;
fixture.detectChanges();
const mockTrigger = new SimpleTrigger();
mockTrigger.triggerKeyDTO = new TriggerKey('test-trigger', null);
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: 'TestJob', description: null};
mockTrigger.triggerKeyDTO = new TriggerKey(testTriggerName, null);
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: testJobName, description: null};
mockTrigger.mayFireAgain = true;
mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW;
const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`);
const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`);
getSimpleTriggerReq.flush(mockTrigger);
component.simpleTriggerReactiveForm.setValue({
triggerName: 'test-trigger',
jobClass: 'TestJob',
triggerName: testTriggerName,
jobClass: testJobName,
triggerRecurrence: {
repeatInterval: 2000,
repeatCount: 100,
@@ -178,10 +183,10 @@ describe('SimpleTriggerConfig', () => {
fixture.detectChanges();
const componentDe: DebugElement = fixture.debugElement;
setInputValue(componentDe, '#repeatInterval', '4000');
setInputValue(componentDe, repeatIntervalSelector, '4000');
expect(component.simpleTriggerReactiveForm.controls.triggerRecurrence.value.repeatInterval).toEqual(4000);
const submitButton = componentDe.query(By.css('form button[color="primary"]'));
const submitButton = componentDe.query(By.css(submitButtonSelector));
expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit');
let actualNewTrigger;
@@ -189,7 +194,7 @@ describe('SimpleTriggerConfig', () => {
submitButton.nativeElement.click();
const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`);
const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`);
putSimpleTriggerReq.flush(mockTrigger);
expect(actualNewTrigger).toBeUndefined();
@@ -220,8 +225,34 @@ describe('SimpleTriggerConfig', () => {
fixture.detectChanges();
const componentDe: DebugElement = fixture.debugElement;
const submitButton = componentDe.query(By.css('form button[color="primary"]'));
const submitButton = componentDe.query(By.css(submitButtonSelector));
expect(submitButton.nativeElement.textContent.trim()).toEqual('Submit');
expect(component.simpleTriggerReactiveForm.value.triggerName).toBeNull();
});
it('should reset the form when a new trigger is selected', () => {
const mockTriggerKey = new TriggerKey(testTriggerName, null);
component.triggerKey = mockTriggerKey;
const mockTrigger = new SimpleTrigger();
mockTrigger.triggerKeyDTO = mockTriggerKey;
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: testJobName, description: null};
mockTrigger.mayFireAgain = true;
mockTrigger.misfireInstruction = MisfireInstruction.MISFIRE_INSTRUCTION_FIRE_NOW;
const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/${testTriggerName}`);
getSimpleTriggerReq.flush(mockTrigger);
expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName);
component.triggerKey = null;
expect(component.simpleTriggerReactiveForm.value.triggerName).toBeNull();
expect(component.simpleTriggerReactiveForm.value.jobClass).toBeNull();
expect(component.shouldShowTheTriggerCardContent()).toBeTruthy();
});
it('should display the warning if there are no eligible jobs', () => {

View File

@@ -34,9 +34,8 @@ export class SimpleTriggerConfigComponent implements OnInit {
scheduler: Scheduler;
triggerLoading = true;
triggerLoading = false;
private fetchedTriggers = false;
private triggerInProgress = false;
private selectedTriggerKey: TriggerKey;
@@ -48,6 +47,9 @@ export class SimpleTriggerConfigComponent implements OnInit {
@Output()
onNewTrigger = new EventEmitter<SimpleTrigger>();
@Output()
triggerFormOpenChange = new EventEmitter<boolean>();
constructor(
private formBuilder: UntypedFormBuilder,
private schedulerService: SchedulerService,
@@ -65,18 +67,37 @@ export class SimpleTriggerConfigComponent implements OnInit {
openTriggerForm() {
this.enabledTriggerForm = true;
this.triggerFormOpenChange.emit(this.enabledTriggerForm);
}
private closeTriggerForm() {
this.enabledTriggerForm = false;
this.triggerFormOpenChange.emit(this.enabledTriggerForm);
}
@Input()
set triggerKey(triggerKey: TriggerKey) {
this.selectedTriggerKey = {...triggerKey} as TriggerKey;
this.fetchSelectedTrigger();
if (!triggerKey) {
this.openNewTriggerForm();
} else if (!this.selectedTriggerKey || this.selectedTriggerKey.name !== triggerKey.name) {
this._resetTheTrigger();
this.selectedTriggerKey = {...triggerKey} as TriggerKey;
this.fetchSelectedTrigger();
this.closeTriggerForm();
}
}
openNewTriggerForm() {
this._resetTheTrigger();
this.openTriggerForm();
}
private _resetTheTrigger() {
this.trigger = null;
this.triggerInProgress = false;
this.selectedTriggerKey = null;
this.simpleTriggerReactiveForm.reset(new SimpleTriggerReactiveForm());
}
fetchSelectedTrigger = () => {
this.triggerLoading = true;
@@ -94,7 +115,11 @@ export class SimpleTriggerConfigComponent implements OnInit {
existsATriggerInProgress = (): boolean => this.trigger && this.triggerInProgress;
onResetReactiveForm = () => {
this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger));
if (this.trigger) {
this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger));
} else {
this.simpleTriggerReactiveForm.reset(new SimpleTriggerReactiveForm());
}
this.closeTriggerForm();
};
@@ -103,13 +128,13 @@ export class SimpleTriggerConfigComponent implements OnInit {
this.schedulerService.updateSimpleTriggerConfig : this.schedulerService.saveSimpleTriggerConfig;
const simpleTriggerCommand = this._fromReactiveFormToCommand();
this.triggerLoading = true;
schedulerServiceCall(simpleTriggerCommand)
.subscribe((retTrigger: SimpleTrigger) => {
this.trigger = retTrigger;
this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(retTrigger));
this.fetchedTriggers = true;
this.triggerInProgress = this.trigger.mayFireAgain;
if (schedulerServiceCall === this.schedulerService.saveSimpleTriggerConfig) {
@@ -118,8 +143,13 @@ export class SimpleTriggerConfigComponent implements OnInit {
this.closeTriggerForm();
}, error => {
this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger));
});
if (this.trigger) {
this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(this.trigger));
}
this.triggerLoading = false;
}, () => {
this.triggerLoading = false
});
}

View File

@@ -9,7 +9,9 @@
<mat-card-content style="position: relative; height: 100%">
<mat-nav-list style="overflow-y: auto; position: absolute; left: 0; right: 0; top: 0; bottom: 0; overflow: auto; height: calc(100% - 3em)">
<mat-list-item *ngFor="let triggerKey of getTriggerKeyList()" class="triggerItemList"
[ngClass]="{'selectedTrigger': selectedTrigger && selectedTrigger.name==triggerKey.name}">
[ngClass]="{'selectedTrigger': selectedTrigger && selectedTrigger.name==triggerKey.name}"
(click)="selectTrigger(triggerKey)"
>
<a matLine>{{ triggerKey.name }}</a>
</mat-list-item>
</mat-nav-list>

View File

@@ -81,16 +81,17 @@ export class TriggerListComponent implements OnInit {
}
onNewTriggerBtnClicked() {
if (this.getTriggerKeyList() && this.getTriggerKeyList().length > 0) {
this.dialog.open(UnsupportedMultipleJobsDialog)
} else {
this.onNewTriggerClicked.emit();
}
this.onNewTriggerClicked.emit();
// if (this.getTriggerKeyList() && this.getTriggerKeyList().length > 0) {
// this.dialog.open(UnsupportedMultipleJobsDialog)
// } else {
// this.onNewTriggerClicked.emit();
// }
}
onNewTrigger(newTrigger: SimpleTrigger) {
this.newTriggers = [newTrigger, ...this.newTriggers];
this.selectedTrigger = newTrigger.triggerKeyDTO;
this.selectTrigger(newTrigger.triggerKeyDTO);
}
}

View File

@@ -14,7 +14,7 @@
* Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
*/
/***************************************************************************************************
/** *************************************************************************************************
* BROWSER POLYFILLS
*/
@@ -51,14 +51,14 @@ import 'core-js/es7/reflect';
/***************************************************************************************************
/** *************************************************************************************************
* Zone JS is required by Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
/** *************************************************************************************************
* APPLICATION IMPORTS
*/
@@ -68,7 +68,7 @@ import 'zone.js/dist/zone'; // Included with Angular CLI.
*/
// import 'intl'; // Run `npm install --save intl`.
/***************************************************************************************************
/** *************************************************************************************************
* MATERIAL 2
*/
import 'hammerjs/hammer';

View File

@@ -3,9 +3,8 @@ export * from './user.service';
export * from './config.service';
export * from './auth.service';
export * from './scheduler.service';
export * from './websocket.service';
export * from './progress.websocket.service';
export * from './logs.websocket.service';
export * from './progress.rx-websocket.service';
export * from './logs.rx-websocket.service';
export * from './trigger.service'
export * from './job.service'

View File

@@ -0,0 +1,39 @@
import { TestBed } from '@angular/core/testing';
import { LogsRxWebsocketService } from './logs.rx-websocket.service';
import {ApiService} from './api.service';
import {RxStomp} from '@stomp/rx-stomp';
import {jest} from '@jest/globals';
describe('LogsRxWebsocketService', () => {
let service: LogsRxWebsocketService;
let configureSpy;
let activateSpy;
beforeEach(() => {
configureSpy = jest.spyOn(RxStomp.prototype, 'configure');
activateSpy = jest.spyOn(RxStomp.prototype, 'activate').mockImplementation(() => undefined);
TestBed.configureTestingModule({
providers: [
{provide: ApiService, useValue: {getToken: () => 'test-token'}}
]
});
service = TestBed.inject(LogsRxWebsocketService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should configure rx-stomp with the logs websocket endpoint', () => {
expect(configureSpy).toHaveBeenCalled();
expect(activateSpy).toHaveBeenCalled();
const config = configureSpy.mock.calls[configureSpy.mock.calls.length - 1][0];
expect(config.heartbeatIncoming).toEqual(0);
expect(config.heartbeatOutgoing).toEqual(20000);
expect(config.reconnectDelay).toEqual(200);
expect(config.webSocketFactory.toString()).toContain('/logs?access_token=');
});
});

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import {RxStompService} from './rx-stomp.service';
import {ApiService} from './api.service';
import SockJS from 'sockjs-client';
import {CONTEXT_PATH, getBaseUrl} from './config.service';
@Injectable({
providedIn: 'root'
})
export class LogsRxWebsocketService extends RxStompService {
constructor(private apiService: ApiService) {
super({
webSocketFactory: () => new SockJS(`${getBaseUrl()}${CONTEXT_PATH}/logs?access_token=${this.apiService.getToken()}`),
heartbeatIncoming: 0,
heartbeatOutgoing: 20000,
reconnectDelay: 200,
debug: (msg: string): void => {
console.log(new Date(), msg);
}
});
}
}

View File

@@ -1,12 +0,0 @@
import {Injectable} from '@angular/core';
import {WebsocketService, ApiService, getBaseUrl, CONTEXT_PATH} from '.';
import {SocketOption} from '../model/SocketOption.model';
@Injectable()
export class LogsWebsocketService extends WebsocketService {
constructor(private apiService: ApiService) {
super(new SocketOption(getBaseUrl() + `${CONTEXT_PATH}/logs`, '/topic/logs', apiService.getToken))
}
}

View File

@@ -0,0 +1,39 @@
import { TestBed } from '@angular/core/testing';
import {RxStomp} from '@stomp/rx-stomp';
import {jest} from '@jest/globals';
import { ProgressRxWebsocketService } from './progress.rx-websocket.service';
import {ApiService} from './api.service';
describe('ProgressRxWebsocketService', () => {
let service: ProgressRxWebsocketService;
let configureSpy;
let activateSpy;
beforeEach(() => {
configureSpy = jest.spyOn(RxStomp.prototype, 'configure');
activateSpy = jest.spyOn(RxStomp.prototype, 'activate').mockImplementation(() => undefined);
TestBed.configureTestingModule({
providers: [
{provide: ApiService, useValue: {getToken: () => 'test-token'}}
]
});
service = TestBed.inject(ProgressRxWebsocketService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should configure rx-stomp with the progress websocket endpoint', () => {
expect(configureSpy).toHaveBeenCalled();
expect(activateSpy).toHaveBeenCalled();
const config = configureSpy.mock.calls[configureSpy.mock.calls.length - 1][0];
expect(config.heartbeatIncoming).toEqual(0);
expect(config.heartbeatOutgoing).toEqual(20000);
expect(config.reconnectDelay).toEqual(200);
expect(config.webSocketFactory.toString()).toContain('/progress?access_token=');
});
});

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import {RxStompService} from './rx-stomp.service';
import {ApiService} from './api.service';
import SockJS from 'sockjs-client';
import {CONTEXT_PATH, getBaseUrl} from './config.service';
@Injectable({
providedIn: 'root'
})
export class ProgressRxWebsocketService extends RxStompService {
constructor(private apiService: ApiService) {
super({
webSocketFactory: () => new SockJS(`${getBaseUrl()}${CONTEXT_PATH}/progress?access_token=${this.apiService.getToken()}`),
heartbeatIncoming: 0,
heartbeatOutgoing: 20000,
reconnectDelay: 200,
debug: (msg: string): void => {
console.log(new Date(), msg);
}
});
}
}

View File

@@ -1,12 +0,0 @@
import {Injectable} from '@angular/core';
import {WebsocketService, ApiService, getBaseUrl, CONTEXT_PATH} from '.';
import {SocketOption} from '../model/SocketOption.model';
@Injectable()
export class ProgressWebsocketService extends WebsocketService {
constructor(private apiService: ApiService) {
super(new SocketOption(getBaseUrl() + `${CONTEXT_PATH}/progress`, '/topic/progress', apiService.getToken))
}
}

View File

@@ -0,0 +1,12 @@
import {RxStomp} from '@stomp/rx-stomp';
import {RxStompConfig} from '@stomp/rx-stomp/esm6/rx-stomp-config';
export class RxStompService extends RxStomp {
constructor(rxStompConfig: RxStompConfig) {
super();
super.configure(rxStompConfig);
super.activate();
}
}

View File

@@ -35,7 +35,7 @@ export class UserService {
this.currentUser = user;
this.router.initialNavigation();
}, err => {
console.log(`error retrieving current user due to ` + JSON.stringify(err));
console.log('error retrieving current user due to ' + JSON.stringify(err));
const httpErrorResponse = err as HttpErrorResponse;
if (httpErrorResponse.status === 404) {
this.isAnAnonymousUser = true;

View File

@@ -1,136 +0,0 @@
import {Observable, Subscriber} from 'rxjs';
import {SocketEndpoint} from '../model/SocketEndpoint.model'
import Stomp from 'stompjs';
import SockJS from 'sockjs-client';
import {SocketOption} from '../model/SocketOption.model';
interface WebsocketSubscriber {
index: number,
observer: Subscriber<any>
}
export interface QuartzManagerWebsocketMessage {
type: string;
message: any;
headers: any;
self: boolean;
}
export class WebsocketService {
_options: SocketOption;
_socket: SocketEndpoint = new SocketEndpoint();
observableStompConnection: Observable<any>;
subscribers: Array<WebsocketSubscriber> = [];
subscriberIndex = 0;
_messageIds: Array<any> = [];
reconnectionPromise: any;
constructor(options: SocketOption) {
this._options = options
this.createObservableSocket();
this.connect();
}
getOptions = () => {
}
private createObservableSocket = () => {
this.observableStompConnection = new Observable((observer) => {
const subscriberIndex = this.subscriberIndex++;
this.addToSubscribers({index: subscriberIndex, observer});
return () => this.removeFromSubscribers(subscriberIndex);
});
}
private addToSubscribers = (subscriber) => {
this.subscribers.push(subscriber);
}
private removeFromSubscribers = (index) => {
this.subscribers = this.subscribers.filter(subscriber => subscriber.index !== index);
}
getObservable = () => {
return this.observableStompConnection;
};
getMessage = function (data): QuartzManagerWebsocketMessage {
const out: QuartzManagerWebsocketMessage = <QuartzManagerWebsocketMessage>{};
out.type = 'SUCCESS';
out.message = JSON.parse(data.body);
out.headers = {};
out.headers.messageId = data.headers['message-id'];
const messageIdIndex = this._messageIds.indexOf(out.headers.messageId);
if (messageIdIndex > -1) {
out.self = true;
this._messageIds = this._messageIds.splice(messageIdIndex, 1);
}
return out;
};
_socketListener = (frame) => {
console.log('Connected: ' + frame);
this._socket.stomp.subscribe(
this._options.topicName,
data => this.subscribers.forEach(subscriber => subscriber.observer.next(this.getMessage(data)))
);
}
_onSocketError = (errorMsg) => {
const out: any = {};
out.type = 'ERROR';
out.message = errorMsg;
this.subscribers.forEach(subscriber => subscriber.observer.error(out));
this.scheduleReconnection();
}
scheduleReconnection = () => {
this.reconnectionPromise = setTimeout(() => {
console.log('Socket reconnecting... (if it fails, next attempt in ' + this._options.reconnectionTimeout + ' msec)');
this.connect();
}, this._options.reconnectionTimeout);
}
reconnectNow = function () {
this._socket.stomp.disconnect();
if (this.reconnectionPromise && this.reconnectionPromise.cancel) {
this.reconnectionPromise.cancel();
}
this.connect();
};
send = (message) => {
const id = Math.floor(Math.random() * 1000000);
this._socket.stomp.send(this._options.brokerName, {
priority: 9
}, JSON.stringify({
message: message,
id: id
}));
this._messageIds.push(id);
};
connect = () => {
const headers = {};
let socketUrl = this._options.socketUrl;
if (this._options.getAccessToken()) {
socketUrl += `?access_token=${this._options.getAccessToken()}`;
}
this._socket.client = new SockJS(socketUrl);
this._socket.stomp = Stomp.over(this._socket.client);
this._socket.stomp.connect(headers, this._socketListener, this._onSocketError);
this._socket.stomp.onclose = this.scheduleReconnection;
}
}

View File

@@ -19,18 +19,25 @@
<div fxFlex="1 1 350px">
<div fxLayout="row" fxFill>
<div fxLayout="column" fxFill>
<qrzmng-simple-trigger-config fxFill
[triggerKey]="selectedTriggerKey"
(onNewTrigger)="onNewTriggerCreated($event)">
</qrzmng-simple-trigger-config>
<qrzmng-simple-trigger-config fxFill
[triggerKey]="selectedTriggerKey"
(triggerFormOpenChange)="setNewTriggerFormOpened($event)"
(onNewTrigger)="onNewTriggerCreated($event)">
</qrzmng-simple-trigger-config>
</div>
</div>
</div>
<div fxFlex="1 1 auto" style="margin-left: 20px;">
<div fxFlex="1 1 auto" fxLayout="column" fxLayoutAlign="start stretch" fxLayoutGap="6px">
<progress-panel></progress-panel>
<logs-panel fxFlex="1 1 auto" fxFill></logs-panel>
<progress-panel
[triggerKey]=selectedTriggerKey
>
</progress-panel>
<logs-panel fxFlex="1 1 auto" fxFill
[triggerKey]=selectedTriggerKey
>
</logs-panel>
</div>
</div>

View File

@@ -1,8 +1,4 @@
import {Component, OnInit, ViewChild} from '@angular/core';
import {
ConfigService,
UserService
} from '../../services';
import {SimpleTrigger} from '../../model/simple-trigger.model';
import {TriggerKey} from '../../model/triggerKey.model';
import {SimpleTriggerConfigComponent} from '../../components/simple-trigger-config';
@@ -32,15 +28,25 @@ export class ManagerComponent implements OnInit {
}
onNewTriggerRequested() {
this.triggerConfigComponent.openTriggerForm();
this.selectedTriggerKey = null;
this.newTriggerFormOpened = true;
if (this.triggerConfigComponent) {
this.triggerConfigComponent.openNewTriggerForm();
}
}
onNewTriggerCreated(newTrigger: SimpleTrigger) {
this.triggerListComponent.onNewTrigger(newTrigger);
this.newTriggerFormOpened = false;
}
setSelectedTrigger(triggerKey: TriggerKey) {
this.selectedTriggerKey = triggerKey;
this.newTriggerFormOpened = false;
}
setNewTriggerFormOpened(opened: boolean) {
this.newTriggerFormOpened = opened;
}
}

View File

@@ -14,7 +14,7 @@
* Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
*/
/***************************************************************************************************
/** *************************************************************************************************
* BROWSER POLYFILLS
*/
@@ -50,7 +50,7 @@ import 'core-js/es6/reflect';
/***************************************************************************************************
/** *************************************************************************************************
* Zone JS is required by Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
@@ -59,7 +59,7 @@ import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
/** *************************************************************************************************
* APPLICATION IMPORTS
*/
@@ -69,7 +69,7 @@ import 'zone.js/dist/zone'; // Included with Angular CLI.
*/
// import 'intl'; // Run `npm install --save intl`.
/***************************************************************************************************
/** *************************************************************************************************
* MATERIAL 2
*/
import 'hammerjs/hammer';

View File

@@ -1,5 +1,5 @@
/* SystemJS module definition */
declare var module: NodeModule;
declare let module: NodeModule;
interface NodeModule {
id: string;
}

View File

@@ -4,4 +4,4 @@
.classpath
.project
.idea
*.iml
/**/*.iml

View File

@@ -10,7 +10,7 @@
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-parent</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
<packaging>pom</packaging>
@@ -51,6 +51,7 @@
<nexus-staging-maven-plugin.version>1.6.7</nexus-staging-maven-plugin.version>
<maven-release-plugin.version>2.5.3</maven-release-plugin.version>
<maven-gpg-plugin.version>3.0.1</maven-gpg-plugin.version>
<sonar-maven-plugin.version>3.11.0.3922</sonar-maven-plugin.version>
<sonar.organization>fabioformosa</sonar.organization>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
<sonar.exclusions>
@@ -79,27 +80,27 @@
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-common</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-api</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-security</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-persistence</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-ui</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
@@ -139,6 +140,11 @@
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-failsafe-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.sonarsource.scanner.maven</groupId>
<artifactId>sonar-maven-plugin</artifactId>
<version>${sonar-maven-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>

View File

@@ -3,7 +3,7 @@
<parent>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-parent</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</parent>
<artifactId>quartz-manager-common</artifactId>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-parent</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</parent>
<artifactId>quartz-manager-starter-api</artifactId>

View File

@@ -38,11 +38,13 @@ public abstract class AbstractQuartzManagerJob implements Job {
LogRecord logMsg = doIt(jobExecutionContext);
log.info(logMsg.getMessage());
String triggerName = jobExecutionContext.getTrigger().getKey().getName();
logMsg.setThreadName(Thread.currentThread().getName());
webSocketLogsNotifier.send(logMsg);
webSocketLogsNotifier.send(triggerName, logMsg);
TriggerFiredBundleDTO triggerFiredBundleDTO = WebSocketProgressNotifier.buildTriggerFiredBundle(jobExecutionContext);
webSocketProgressNotifier.send(triggerFiredBundleDTO);
webSocketProgressNotifier.send(triggerName, triggerFiredBundleDTO);
}
}

View File

@@ -14,7 +14,7 @@ public class WebSocketLogsNotifier implements WebhookSender<LogRecord> {
private SimpMessageSendingOperations messagingTemplate;
@Override
public void send(LogRecord logRecord) {
messagingTemplate.convertAndSend(TOPIC_LOGS, logRecord);
public void send(String triggerName, LogRecord logRecord) {
messagingTemplate.convertAndSend(TOPIC_LOGS + "/" + triggerName, logRecord);
}
}

View File

@@ -20,8 +20,8 @@ public class WebSocketProgressNotifier implements WebhookSender<TriggerFiredBund
private SimpMessageSendingOperations messagingTemplate;
@Override
public void send(TriggerFiredBundleDTO triggerFiredBundleDTO) {
messagingTemplate.convertAndSend(TOPIC_PROGRESS, triggerFiredBundleDTO);
public void send(String triggerName, TriggerFiredBundleDTO triggerFiredBundleDTO) {
messagingTemplate.convertAndSend(TOPIC_PROGRESS + "/" + triggerName, triggerFiredBundleDTO);
}
public static TriggerFiredBundleDTO buildTriggerFiredBundle(JobExecutionContext jobExecutionContext) {

View File

@@ -9,6 +9,6 @@ package it.fabioformosa.quartzmanager.api.websockets;
*/
public interface WebhookSender<T> {
void send(T message);
void send(String triggerName, T message);
}

View File

@@ -4,16 +4,26 @@ import it.fabioformosa.quartzmanager.api.dto.TriggerFiredBundleDTO;
import it.fabioformosa.quartzmanager.api.jobs.entities.LogRecord;
import it.fabioformosa.quartzmanager.api.websockets.WebhookSender;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.quartz.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobExecutionContext;
import org.quartz.JobKey;
import org.quartz.ScheduleBuilder;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
@ExtendWith(MockitoExtension.class)
class SampleJobTest {
@InjectMocks
@@ -24,14 +34,16 @@ class SampleJobTest {
@Mock
private WebhookSender<LogRecord> webSocketLogsNotifier;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Captor
private ArgumentCaptor<LogRecord> logRecordCaptor;
@Captor
private ArgumentCaptor<TriggerFiredBundleDTO> triggerFiredBundleDTOCaptor;
@Test
void givenASampleJob_whenTheJobIsExecuted_thenTheWebhookSendersAreCalled() {
JobExecutionContext jobExecutionContext = Mockito.mock(JobExecutionContext.class);
String triggerName = "test-trigger";
ScheduleBuilder schedulerBuilder = SimpleScheduleBuilder.simpleSchedule()
.withRepeatCount(5)
@@ -40,6 +52,7 @@ class SampleJobTest {
.newJob(SampleJob.class).withIdentity(JobKey.jobKey("test-job"))
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(triggerName)
.forJob(jobDetail)
.withSchedule(schedulerBuilder)
.build();
@@ -47,24 +60,23 @@ class SampleJobTest {
Mockito.when(jobExecutionContext.getJobDetail()).thenReturn(jobDetail);
sampleJob.execute(jobExecutionContext);
Mockito.verify(webSocketLogsNotifier).send(argThat(actualLogRecord -> {
Assertions.assertThat(actualLogRecord.getMessage()).isEqualTo("Hello!");
Assertions.assertThat(actualLogRecord.getType()).isEqualTo(LogRecord.LogType.INFO);
Assertions.assertThat(actualLogRecord.getDate()).isNotNull();
Assertions.assertThat(actualLogRecord.getThreadName()).isNotNull();
return true;
}));
Mockito.verify(webSocketProgressNotifier).send(argThat(triggerFiredBundleDTO -> {
Assertions.assertThat(triggerFiredBundleDTO.getJobKey()).isEqualTo("test-job");
Assertions.assertThat(triggerFiredBundleDTO.getRepeatCount()).isEqualTo(6);
Assertions.assertThat(triggerFiredBundleDTO.getJobClass()).isEqualTo(SampleJob.class.getName());
Assertions.assertThat(triggerFiredBundleDTO.getTimesTriggered()).isZero();
Assertions.assertThat(triggerFiredBundleDTO.getNextFireTime()).isNull();
Assertions.assertThat(triggerFiredBundleDTO.getPercentage()).isZero();
Assertions.assertThat(triggerFiredBundleDTO.getFinalFireTime()).isNotNull();
Assertions.assertThat(triggerFiredBundleDTO.getPreviousFireTime()).isNull();
return true;
}));
Mockito.verify(webSocketLogsNotifier).send(eq(triggerName), logRecordCaptor.capture());
LogRecord actualLogRecord = logRecordCaptor.getValue();
Assertions.assertThat(actualLogRecord.getMessage()).isEqualTo("Hello!");
Assertions.assertThat(actualLogRecord.getType()).isEqualTo(LogRecord.LogType.INFO);
Assertions.assertThat(actualLogRecord.getDate()).isNotNull();
Assertions.assertThat(actualLogRecord.getThreadName()).isNotNull();
Mockito.verify(webSocketProgressNotifier).send(eq(triggerName), triggerFiredBundleDTOCaptor.capture());
TriggerFiredBundleDTO triggerFiredBundleDTO = triggerFiredBundleDTOCaptor.getValue();
Assertions.assertThat(triggerFiredBundleDTO.getJobKey()).isEqualTo("test-job");
Assertions.assertThat(triggerFiredBundleDTO.getRepeatCount()).isEqualTo(6);
Assertions.assertThat(triggerFiredBundleDTO.getJobClass()).isEqualTo(SampleJob.class.getName());
Assertions.assertThat(triggerFiredBundleDTO.getTimesTriggered()).isZero();
Assertions.assertThat(triggerFiredBundleDTO.getNextFireTime()).isNull();
Assertions.assertThat(triggerFiredBundleDTO.getPercentage()).isZero();
Assertions.assertThat(triggerFiredBundleDTO.getFinalFireTime()).isNotNull();
Assertions.assertThat(triggerFiredBundleDTO.getPreviousFireTime()).isNull();
}
@Test

View File

@@ -18,13 +18,17 @@ import java.util.Date;
@SpringBootTest
class SimpleTriggerServiceIntegrationTest {
private static final String SAMPLE_JOB_CLASS = "it.fabioformosa.quartzmanager.api.jobs.SampleJob";
private static final String SAMPLE_EXTRA_JOB_CLASS = "it.fabioformosa.samplepackage.SampleExtraJob";
private static final String FIRST_TRIGGER_SUFFIX = "A";
private static final String SECOND_TRIGGER_SUFFIX = "B";
@Autowired
private SimpleTriggerService simpleTriggerService;
@Test
void givenASimpleTriggerCommandDTOWithAllData_whenANewSimpleTriggerIsScheduled_thenShouldGetATriggertDTO() throws SchedulerException, ClassNotFoundException {
String simpleTriggerTestName = "simpleTriggerWithAllData";
String jobClass = "it.fabioformosa.quartzmanager.api.jobs.SampleJob";
Date startDate = new Date();
Date endDate = DateUtils.addHoursToNow(5);
int repeatCount = 3;
@@ -41,7 +45,7 @@ class SimpleTriggerServiceIntegrationTest {
.repeatCount(repeatCount)
.repeatInterval(repeatInterval)
.misfireInstruction(misfireInstructionFireNow)
.jobClass(jobClass)
.jobClass(SAMPLE_JOB_CLASS)
.build())
.build();
SimpleTriggerDTO simpleTriggerDTO = simpleTriggerService.scheduleSimpleTrigger(simpleTriggerCommand);
@@ -61,12 +65,11 @@ class SimpleTriggerServiceIntegrationTest {
@Test
void givenASimpleTriggerCommandDTOWithMissingOptionalField_whenANewSimpleTriggerIsScheduled_thenShouldGetATriggertDTO() throws SchedulerException, ClassNotFoundException {
String simpleTriggerTestName = "simpleTriggerWithoutOptionalData";
String jobClass = "it.fabioformosa.quartzmanager.api.jobs.SampleJob";
SimpleTriggerCommandDTO simpleTriggerCommand = SimpleTriggerCommandDTO.builder()
.triggerName(simpleTriggerTestName)
.simpleTriggerInputDTO(SimpleTriggerInputDTO.builder()
.jobClass(jobClass)
.jobClass(SAMPLE_JOB_CLASS)
.build())
.build();
SimpleTriggerDTO simpleTriggerDTO = simpleTriggerService.scheduleSimpleTrigger(simpleTriggerCommand);
@@ -81,4 +84,49 @@ class SimpleTriggerServiceIntegrationTest {
Assertions.assertThat(simpleTriggerDTO.getRepeatInterval()).isZero();
}
@Test
void givenTwoSimpleTriggerCommandDTOsForTheSameJob_whenScheduled_thenShouldCreateTwoTriggers() throws SchedulerException, ClassNotFoundException {
String triggerNamePrefix = "sameJobTrigger" + System.nanoTime();
SimpleTriggerDTO firstTrigger = simpleTriggerService.scheduleSimpleTrigger(
buildSimpleTriggerCommand(triggerNamePrefix + FIRST_TRIGGER_SUFFIX, SAMPLE_JOB_CLASS)
);
SimpleTriggerDTO secondTrigger = simpleTriggerService.scheduleSimpleTrigger(
buildSimpleTriggerCommand(triggerNamePrefix + SECOND_TRIGGER_SUFFIX, SAMPLE_JOB_CLASS)
);
Assertions.assertThat(firstTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + FIRST_TRIGGER_SUFFIX);
Assertions.assertThat(secondTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + SECOND_TRIGGER_SUFFIX);
Assertions.assertThat(firstTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_JOB_CLASS);
Assertions.assertThat(secondTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_JOB_CLASS);
Assertions.assertThat(firstTrigger.getJobKeyDTO().getName()).isNotEqualTo(secondTrigger.getJobKeyDTO().getName());
}
@Test
void givenTwoSimpleTriggerCommandDTOsForDifferentJobs_whenScheduled_thenShouldCreateTwoTriggers() throws SchedulerException, ClassNotFoundException {
String triggerNamePrefix = "differentJobTrigger" + System.nanoTime();
SimpleTriggerDTO firstTrigger = simpleTriggerService.scheduleSimpleTrigger(
buildSimpleTriggerCommand(triggerNamePrefix + FIRST_TRIGGER_SUFFIX, SAMPLE_JOB_CLASS)
);
SimpleTriggerDTO secondTrigger = simpleTriggerService.scheduleSimpleTrigger(
buildSimpleTriggerCommand(triggerNamePrefix + SECOND_TRIGGER_SUFFIX, SAMPLE_EXTRA_JOB_CLASS)
);
Assertions.assertThat(firstTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + FIRST_TRIGGER_SUFFIX);
Assertions.assertThat(secondTrigger.getTriggerKeyDTO().getName()).isEqualTo(triggerNamePrefix + SECOND_TRIGGER_SUFFIX);
Assertions.assertThat(firstTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_JOB_CLASS);
Assertions.assertThat(secondTrigger.getJobDetailDTO().getJobClassName()).isEqualTo(SAMPLE_EXTRA_JOB_CLASS);
}
private static SimpleTriggerCommandDTO buildSimpleTriggerCommand(String triggerName, String jobClass) {
return SimpleTriggerCommandDTO.builder()
.triggerName(triggerName)
.simpleTriggerInputDTO(SimpleTriggerInputDTO.builder()
.jobClass(jobClass)
.startDate(DateUtils.addHoursToNow(1))
.build())
.build();
}
}

View File

@@ -0,0 +1,48 @@
package it.fabioformosa.quartzmanager.api.websockets;
import it.fabioformosa.quartzmanager.api.dto.TriggerFiredBundleDTO;
import it.fabioformosa.quartzmanager.api.jobs.entities.LogRecord;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import static org.mockito.MockitoAnnotations.openMocks;
class WebSocketNotifierTest {
@InjectMocks
private WebSocketLogsNotifier webSocketLogsNotifier;
@InjectMocks
private WebSocketProgressNotifier webSocketProgressNotifier;
@Mock
private SimpMessageSendingOperations messagingTemplate;
@BeforeEach
void setUp() {
openMocks(this);
}
@Test
void givenATriggerName_whenALogIsSent_thenShouldSendItToTheTriggerLogsTopic() {
LogRecord logRecord = new LogRecord(LogRecord.LogType.INFO, "Hello!");
webSocketLogsNotifier.send("trigger-1", logRecord);
Mockito.verify(messagingTemplate).convertAndSend("/topic/logs/trigger-1", logRecord);
}
@Test
void givenATriggerName_whenProgressIsSent_thenShouldSendItToTheTriggerProgressTopic() {
TriggerFiredBundleDTO triggerFiredBundleDTO = new TriggerFiredBundleDTO();
webSocketProgressNotifier.send("trigger-1", triggerFiredBundleDTO);
Mockito.verify(messagingTemplate).convertAndSend("/topic/progress/trigger-1", triggerFiredBundleDTO);
}
}

View File

@@ -3,7 +3,7 @@
<parent>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-parent</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</parent>
<artifactId>quartz-manager-starter-persistence</artifactId>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-parent</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</parent>
<artifactId>quartz-manager-starter-security</artifactId>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-parent</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</parent>
<artifactId>quartz-manager-starter-ui</artifactId>

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-parent</artifactId>
<version>4</version>
<version>4.0.11-SNAPSHOT</version>
</parent>
<packaging>jar</packaging>