Compare commits

..

103 Commits

Author SHA1 Message Date
Fabio Formosa
cc433bb531 Merge pull request #136 from fabioformosa/feature/#9x_trigger_types
Feature/#9x trigger types
2026-05-14 08:26:03 +02:00
Fabio Formosa
501ef6c062 fixed sonar issues 2026-05-14 08:22:33 +02:00
Fabio Formosa
b6529b453a fixed the coverage 2026-05-14 00:48:08 +02:00
Fabio Formosa
40d8c952a0 fixed the coverage 2026-05-14 00:47:39 +02:00
Fabio Formosa
c1511b54f8 fixed tests and github actions 2026-05-14 00:17:54 +02:00
Fabio Formosa
9a50949fcc fixed tests and github actions 2026-05-14 00:06:34 +02:00
Fabio Formosa
699e661d81 restyled the login page 2026-05-13 22:48:56 +02:00
Fabio Formosa
93990a5994 #90 #91 #92 #93 supported all trigger types 2026-05-12 22:53:11 +02:00
Fabio Formosa
82e684f0a7 #135 added scheduled job management 2026-05-12 22:13:03 +02:00
Fabio Formosa
7d481247bc #132 #133 refactored some existing endpoint and added new ones to manage triggers 2026-05-12 21:41:31 +02:00
Fabio Formosa
e24c5bc62a #134 added the new UI layout and style 2026-05-12 00:59:14 +02:00
Fabio Formosa
29fff2a6cd #130 imported the new version of metamorphosis lib and reverted the conversion service 2026-05-09 15:54:43 +02:00
Fabio Formosa
2a20b930f0 #130 upgraded github actions 2026-05-09 12:06:11 +02:00
Fabio Formosa
b0ba230abe #130 upgraded Spring Boot to 4.0.6 2026-05-09 11:48:39 +02:00
Fabio Formosa
1e48e1803f #130 upgraded Spring Boot to 4.0.6 2026-05-09 11:48:24 +02:00
Fabio Formosa
e90648c027 #131 upgraded Angular to v21 2026-05-09 10:51:18 +02:00
Fabio Formosa
23417fa6a2 Merge branch 'feature/#101_angular15_update' into develop 2026-05-09 00:11:12 +02:00
Fabio Formosa
57d5ebd641 set the new snapshot version 4.1.1-SNAPSHOT 2026-05-08 23:52:34 +02:00
Fabio Formosa
da8c9d5707 Remove Maven Central badge from README
Removed Maven Central badge from README.
2026-05-08 22:53:46 +02:00
Fabio Formosa
6b245c7eec released v4.1.1 2026-05-08 22:36:40 +02:00
Fabio Formosa
d7653dc73e released v4.1.0 2026-05-08 21:50:38 +02:00
Fabio Formosa
e6a7b35f6a released v4.1.0 2026-05-08 21:48:57 +02:00
Fabio Formosa
f6e02ae181 #103 fixed log retrieval through websocket 2026-05-08 21:41:04 +02:00
Fabio Formosa
f6d6cd16e7 Merge branch 'develop' into feature/#101_angular15_update
# Conflicts:
#	quartz-manager-frontend/package-lock.json
#	quartz-manager-frontend/src/app/components/logs-panel/logs-panel.component.html
#	quartz-manager-frontend/src/app/components/progress-panel/progress-panel.component.html
#	quartz-manager-frontend/src/app/components/simple-trigger-config/simple-trigger-config.component.ts
#	quartz-manager-frontend/src/app/views/manager/manager.component.html
#	quartz-manager-frontend/src/app/views/manager/manager.component.ts
2026-05-08 00:59:59 +02:00
Fabio Formosa
87901fe6a7 #103 fixed some font sizes 2026-05-08 00:42:08 +02:00
Fabio Formosa
3088a2fec1 Merge pull request #129 from fabioformosa/develop
Develop for releasing 4.1.0
2026-05-08 00:39:01 +02:00
Fabio Formosa
bdd0caa026 Merge pull request #128 from fabioformosa/feature/#103_multiple_triggers
Feature/#103 multiple triggers
2026-05-06 23:46:02 +02:00
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
56d9f5d94f bump to version 4.0.10 2025-05-23 23:04:28 +02:00
Fabio Formosa
45f66d51fe Merge remote-tracking branch 'origin/master' into develop 2025-05-23 23:00:37 +02:00
Fabio Formosa
95769248a3 migrated to the new maven central repo 2025-05-23 21:52:30 +02:00
Fabio Formosa
dcbf3eb277 migrated to the new maven central repo 2025-05-23 20:10:53 +02:00
Fabio Formosa
fab977fd7d added the staging and prod env to the delivery pipeline 2024-07-27 17:41:31 +02:00
Fabio Formosa
9d2a01ebbe added the staging and prod env to the delivery pipeline 2024-07-27 17:33:31 +02:00
Fabio Formosa
9a0789cab0 minor commit 2024-07-27 17:08:11 +02:00
Fabio Formosa
e5a6b8b32b minor commit 2024-07-27 16:33:24 +02:00
Fabio Formosa
4537c8e304 minor commit 2024-07-18 23:53:27 +02:00
Fabio Formosa
82ac821b34 minor commit 2024-07-17 00:30:49 +02:00
Fabio Formosa
0a4a31ae65 fixed the chart path 2024-07-17 00:21:24 +02:00
Fabio Formosa
3b325536e8 fixed the dev cluster reference 2024-07-17 00:06:58 +02:00
Fabio Formosa
307c6eab98 fixed the docker image in the cloud deploy pipeline 2024-07-16 23:49:38 +02:00
Fabio Formosa
b1ff70265f added verbosity to cloudbuild.yaml 2024-07-16 23:15:54 +02:00
Fabio Formosa
226296737d added verbosity to cloudbuild.yaml 2024-07-16 23:05:34 +02:00
Fabio Formosa
a5750d1d0d duplicated the helm props replacements 2024-07-16 00:24:10 +02:00
Fabio Formosa
f71c9b20ab minor commit 2024-07-16 00:15:05 +02:00
Fabio Formosa
9cc55492dc fixed the pipeline name 2024-07-15 23:58:08 +02:00
Fabio Formosa
6d36e4620c fixed the path of the pipeline file 2024-07-15 23:47:32 +02:00
Fabio Formosa
4d5e8f62c3 added a google deploy pipeline 2024-07-15 23:39:51 +02:00
Fabio Formosa
8ba33f25b4 fixed copy path in the Dockerfile 2024-07-13 16:00:44 +02:00
Fabio Formosa
7a742d5aea fixed copy path in the Dockerfile 2024-07-13 15:52:40 +02:00
Fabio Formosa
abd25d6158 fixed a wrong path in cloudbuild.yaml 2024-07-13 15:33:10 +02:00
Fabio Formosa
9f46e52564 added a logging policy to cloudbuild.yaml and pushed skaffold.yaml 2024-07-13 15:28:24 +02:00
Fabio Formosa
e6927209e5 added a helm chart to the web-showcase module and cloudbuild.yaml and skaffold.yaml 2024-07-13 13:00:12 +02:00
Fabio Formosa
63fbedbdc8 moved the web-showcase from war to jar and added a dockerfile 2024-07-12 00:36:47 +02:00
Fabio Formosa
6ec886686f #101 added smaller fonts for job class and misfire instructions 2024-03-25 23:29:32 +01:00
Fabio Formosa
f96e356c8a Merge branch 'feature/#101_angular15_update' of github.com:fabioformosa/quartz-manager into feature/#101_angular15_update
# Conflicts:
#	.gitignore
#	quartz-manager-frontend/src/styles.css
2024-03-25 22:40:54 +01:00
Fabio Formosa
c646624e45 Merge pull request #122 from ChistaDATA/feature/#101_angular15_update
fix: update footer font size
2024-03-25 22:39:37 +01:00
Fabio Formosa
0148056b1f Update README.md 2024-03-07 23:32:51 +01:00
Midhun A Darvin
e9542352b5 feat: update footer font size 2024-03-04 20:14:34 +05:30
Midhun A Darvin
7cef35517b fix: style regression issues
- add Roboto font face as dependency
- fix font size regression issue in select inputs
- fix scroll regression issue
- fix triggerName not disabled during reschedule
2024-02-19 23:42:37 +01:00
Midhun A Darvin
bc0619a92a feat: update padding & styles
- apply padding fixes
- remove `enableTrigger` variable and directly use `simpleTriggerReactiveForm.enabled`
-  apply default styles of matInput
2024-02-19 23:42:37 +01:00
Midhun A Darvin
2debf6c63f feat: update angular version from 14 to 15
- update package.json dependencies
- npm audit force fix
- add `MatDialogModule` to app module imports
- mat-form-field has appearance property `outline` and `fill` now
- update unit tests
2024-02-19 23:42:32 +01:00
Fabio Formosa
baa01d10cb Merge pull request #121 from ChistaDATA/feature/#101_angular15_update
fix: style regression issues
2024-02-19 23:34:30 +01:00
Midhun A Darvin
adb4864c85 fix: style regression issues
- add Roboto font face as dependency
- fix font size regression issue in select inputs
- fix scroll regression issue
- fix triggerName not disabled during reschedule
2024-02-09 20:23:19 +05:30
Fabio Formosa
c6f10e04eb Merge pull request #114 from ChistaDATA/feature/#101_angular15_update
feat: update padding & styles
2024-02-08 22:56:02 +01:00
Fabio Formosa
ad6a61f3df Update README.md
added a new example for the repo quartz-manager-use-cases
2024-02-07 00:24:21 +01:00
Fabio Formosa
5cb73019de bump version to 4.0.10-SNAPSHOT 2024-02-07 00:19:17 +01:00
Fabio Formosa
8a8e878e47 Update README.md
updated all references to the last release
2024-02-07 00:16:40 +01:00
Fabio Formosa
fae82e1d4e Merge pull request #118 from fabioformosa/develop
Develop to Master
2024-02-07 00:15:44 +01:00
Fabio Formosa
e0011913c2 bump version to 4.0.9 2024-02-06 23:58:07 +01:00
Fabio Formosa
a59b6a6c96 #115 fixed the liquibase DB migration execution 2024-02-06 23:43:54 +01:00
Fabio Formosa
68aaab6ac4 #115 fixed the liquibase DB migration execution 2024-02-06 23:42:38 +01:00
Midhun A Darvin
c75190513a feat: update padding & styles
- apply padding fixes
- remove `enableTrigger` variable and directly use `simpleTriggerReactiveForm.enabled`
-  apply default styles of matInput
2024-02-03 12:00:37 +05:30
Fabio Formosa
1421c52c34 Merge pull request #111 from midhunadarvin/feat/angular-update
feat: update angular version from 14 to 15
2024-02-03 00:11:11 +01:00
Fabio Formosa
b2942737af Merge branch 'feature/#101_angular15_update' into feat/angular-update 2024-02-03 00:07:25 +01:00
Fabio Formosa
a1d8b12e98 Merge pull request #112 from fabioformosa/feature/#109_configure_trigger_job_data
Feature/#109 configure trigger job data
2024-02-02 23:59:20 +01:00
Fabio Formosa
45d6a4c59a Merge branch 'feature/#109_configure_trigger_job_data' of github.com:fabioformosa/quartz-manager into feature/#109_configure_trigger_job_data 2024-02-02 23:54:31 +01:00
Midhun A Darvin
e6f6fb5f06 feat: configure Trigger JobData
- add `jobDataMap` property to SimpleTriggerInputDTO
- update SimpleTriggerCommandDTOToSimpleTrigger converter
- update SimpleTriggerToSimpleTriggerDTO converter
- update unit tests
- add .idea and .iml to .gitignore for ignoring intellij files
2024-02-02 23:43:27 +01:00
Fabio Formosa
13c438d097 Merge branch 'master' into develop 2024-02-02 23:38:22 +01:00
Fabio Formosa
412e455907 #103 step into accomodating the new trigger logic 2024-02-02 22:58:56 +01:00
Fabio Formosa
75d630aad0 Merge pull request #110 from midhunadarvin/feat/configure-trigger-job-data
feat: configure `Trigger` JobDataMap
2024-02-02 22:56:20 +01:00
Midhun A Darvin
2105e289ac feat: update angular version from 14 to 15
- update package.json dependencies
- npm audit force fix
- add `MatDialogModule` to app module imports
- mat-form-field has appearance property `outline` and `fill` now
- update unit tests
2024-02-02 16:05:54 +05:30
Midhun A Darvin
9eddc0b1fd feat: configure Trigger JobData
- add `jobDataMap` property to SimpleTriggerInputDTO
- update SimpleTriggerCommandDTOToSimpleTrigger converter
- update SimpleTriggerToSimpleTriggerDTO converter
- update unit tests
- add .idea and .iml to .gitignore for ignoring intellij files
2024-02-02 13:11:00 +05:30
Fabio Formosa
fa4ede5179 Merge pull request #108 from midhunadarvin/feat/configure-auto-startup
feat: make autoStartup configurable for SchedulerFactoryBean
2024-02-01 00:24:21 +01:00
Fabio Formosa
3aa672031a Updated the sonarcloud.io integration 2024-01-31 23:40:26 +01:00
Midhun A Darvin
82ca186bff feat: make autoStartup configurable for SchedulerFactoryBean
- check property `org.quartz.scheduler.isAutoStartup` and enable autoStartup if it is true
2024-01-31 13:28:15 +05:30
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
Fabio Formosa
527ee1200a #103 added some comments 2022-12-11 20:38:55 +01:00
Fabio Formosa
7fd174b313 bump version to 4.0.9-SNAPSHOT 2022-12-10 16:56:05 +01:00
220 changed files with 24642 additions and 27825 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
# .dockerignore
quartz-manager-frontend/node_modules

View File

@@ -12,14 +12,14 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Java 11 for publishing to Maven Central Repository
uses: actions/setup-java@v3
- name: Set up Java 21 for publishing to Maven Central Repository
uses: actions/setup-java@v4
with:
java-version: '11'
java-version: '21'
distribution: 'temurin'
server-id: ossrh
server-id: maven-central-release
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
gpg-private-key: ${{ secrets.OSSRH_GPG_SECRET_KEY }}
@@ -31,14 +31,14 @@ jobs:
- name: Publish to maven central
run: mvn deploy --file quartz-manager-parent/pom.xml --batch-mode -P "release-maven-central,build-webjar"
env:
MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.OSSRH_TOKEN }}
MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_TOKEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASSWORD }}
MAVEN_GPG_PASSPHRASE: ${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }}
- name: Set up Java 11 for publishing to GitHub Packages
uses: actions/setup-java@v3
- name: Set up Java 21 for publishing to GitHub Packages
uses: actions/setup-java@v4
with:
java-version: '11'
java-version: '21'
distribution: 'temurin'
- name: Publish to GitHub Packages Apache Maven
run: mvn deploy --file quartz-manager-parent/pom.xml -P "deploy-github,build-webjar"

View File

@@ -25,11 +25,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v3
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '11'
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build and test with Maven

View File

@@ -25,13 +25,13 @@ jobs:
strategy:
matrix:
node-version: [16.x]
node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

View File

@@ -7,27 +7,28 @@ on:
# paths: [ 'quartz-manager-parent/**' ]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
jobs:
build:
name: Build and analyze
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK 11
uses: actions/setup-java@v1
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: 11
java-version: 21
distribution: 'zulu' # Alternative distribution options are available.
- name: Cache SonarCloud packages
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache Maven packages
uses: actions/cache@v1
uses: actions/cache@v3
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}

3
.gitignore vendored
View File

@@ -1 +1,4 @@
/.project
.idea
*.iml
.DS_Store

View File

@@ -1,3 +1,12 @@
## **v4.1.1**
**NEW FEATURE** support for multiple triggers
## **v4.0.10**
Migrated to the new maven central repo
## **v4.0.9**
Fixed a bug which prevented to run the liquibase migration scripts in case of usage of quartz-manager-starter-persistence
## **v4.0.8**
Upgraded the frontend to angular v14

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM maven:3.9.8-eclipse-temurin-21 AS build
# Set the working directory
WORKDIR /app
# Copy the pom.xml and download dependencies
COPY quartz-manager-parent/pom.xml ./quartz-manager-parent/
COPY quartz-manager-parent/lombok.config ./quartz-manager-parent/
COPY quartz-manager-parent/quartz-manager-common ./quartz-manager-parent/quartz-manager-common/
COPY quartz-manager-parent/quartz-manager-starter-api ./quartz-manager-parent/quartz-manager-starter-api/
COPY quartz-manager-parent/quartz-manager-starter-persistence ./quartz-manager-parent/quartz-manager-starter-persistence/
COPY quartz-manager-parent/quartz-manager-starter-security ./quartz-manager-parent/quartz-manager-starter-security/
COPY quartz-manager-parent/quartz-manager-starter-ui ./quartz-manager-parent/quartz-manager-starter-ui/
COPY quartz-manager-parent/quartz-manager-web-showcase ./quartz-manager-parent/quartz-manager-web-showcase/
COPY quartz-manager-parent/lombok.config ./quartz-manager-parent/
COPY quartz-manager-frontend ./quartz-manager-frontend/
WORKDIR /app/quartz-manager-parent
RUN mvn clean package -DskipTests -P=build-webjar
# Stage 2: Create the final image
FROM openjdk:11-jre-slim
# Set the working directory
WORKDIR /app
# Copy the JAR file from the build stage
COPY --from=build /app/quartz-manager-parent/quartz-manager-web-showcase/target/*-SNAPSHOT.jar app.jar
# Expose the application port
EXPOSE 8080
# Run the application
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -1,8 +1,9 @@
[![Java CI with Maven](https://github.com/fabioformosa/quartz-manager/actions/workflows/maven.yml/badge.svg)](https://github.com/fabioformosa/quartz-manager/actions/workflows/maven.yml)
[![npm CI](https://github.com/fabioformosa/quartz-manager/actions/workflows/npm.yml/badge.svg)](https://github.com/fabioformosa/quartz-manager/actions/workflows/npm.yml)
[![Maven Central](https://maven-badges.herokuapp.com/maven-central/it.fabioformosa.quartz-manager/quartz-manager-starter-api/badge.svg)](https://maven-badges.herokuapp.com/maven-central/it.fabioformosa.quartz-manager/quartz-manager-starter-api)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=fabioformosa_quartz-manager&metric=coverage)](https://sonarcloud.io/summary/new_code?id=fabioformosa_quartz-manager) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=fabioformosa_quartz-manager&metric=bugs)](https://sonarcloud.io/summary/new_code?id=fabioformosa_quartz-manager) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=fabioformosa_quartz-manager&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=fabioformosa_quartz-manager) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=fabioformosa_quartz-manager&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=fabioformosa_quartz-manager)
# Table Of Contents
[QUARTZ MANAGER](https://github.com/fabioformosa/quartz-manager#quartz-manager)
    [Quartz Manager UI](https://github.com/fabioformosa/quartz-manager#quartz-manager-ui)
@@ -80,12 +81,12 @@ Add the dependency, make eligible for Quart Manager the job classes and set the
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-api</artifactId>
<version>4.0.8</version>
<version>4.0.9</version>
</dependency>
```
#### Gradle
```
implementation group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-api', version: '4.0.8'
implementation group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-api', version: '4.0.9'
```
### Step 2. Quartz Manager Job Classes
@@ -168,12 +169,12 @@ You can optionally import the following dependency to have the UI Dashboard to i
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-ui</artifactId>
<version>4.0.8</version>
<version>4.0.9</version>
</dependency>
```
#### Gradle
```
implementation group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-ui', version: '4.0.8'
implementation group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-ui', version: '4.0.9'
```
### Reach out the UI Console at URL
@@ -203,14 +204,14 @@ Future development: the Quart Manager Security OAuth2 client.
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-security</artifactId>
<version>4.0.8</version>
<version>4.0.9</version>
</dependency>
```
#### Gradle
```
compile group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-security', version: '4.0.8'
compile group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-security', version: '4.0.9'
```
@@ -240,14 +241,14 @@ The pre-requesite is the availability of Postgresql database where Quartz Manage
<dependency>
<groupId>it.fabioformosa.quartz-manager</groupId>
<artifactId>quartz-manager-starter-persistence</artifactId>
<version>4.0.8</version>
<version>4.0.9</version>
</dependency>
```
#### Gradle
```
compile group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-persistence', version: '4.0.8'
compile group: 'it.fabioformosa.quartz-manager', name: 'quartz-manager-starter-persistence', version: '4.0.9'
```
### Quartz Manager Persistence Lib - App Props
@@ -269,6 +270,7 @@ You can find some examples of different scenarios of projects which import Quart
* *existing-security-header-based* - It demonstrates how Quartz Manager Security can coexists with another Spring Security Config present in your project. Imported libraries: Quartz Manager API, Quartz Manager UI and Quartz Manager Security.
* *existing-quartz* - It demonstrates how to Quartz Manager can coexist with a Quartz instance already present in your project Imported libraries: Quartz Manager API, Quartz Manager UI.
* *existing-quartz-and-storage* - It demonstrates how to Quartz Manager Persistence can coexist with a Quartz instance already present in your project. Imported libraries: Quartz Manager API, Quartz Manager UI and Quartz Manager Persistence.
* *with-persistence* - It demonstrates how to import the Quartz Manager Persistence and get created the quartz tables automatically at the bootstrap
## Limitations

45
cloudbuild.yaml Normal file
View File

@@ -0,0 +1,45 @@
substitutions:
_REGION: europe-west8
steps:
# Step 1: Google Cloud Build - Docker build&push
- name: 'gcr.io/k8s-skaffold/skaffold'
entrypoint: 'sh'
args:
- -xe
- -c
- |
# Build and push images
sed -i s/_IMAGE_TAG_POLICY/$SHORT_SHA/g skaffold.yaml
sed -i s/_HELM_APP_VERSION/$SHORT_SHA/g ./quartz-manager-parent/quartz-manager-web-showcase/helm/Chart.yaml
skaffold build --file-output=/workspace/artifacts.json \
--default-repo=${_REGION}-docker.pkg.dev/quartz-manager-test/quartz-manager/quartz-manager-standalone \
--push=true
# Step 2: Google Cloud Deploy - deploy
- name: 'google/cloud-sdk:latest'
entrypoint: 'sh'
args:
- -xe
- -c
- |
sed -i s/_HELM_APP_VERSION/$SHORT_SHA/g ./quartz-manager-parent/quartz-manager-web-showcase/helm/Chart.yaml
gcloud config set deploy/region ${_REGION}
gcloud deploy apply --file ./quartz-manager-parent/quartz-manager-web-showcase/deploy/pipeline.yaml
gcloud deploy apply --file ./quartz-manager-parent/quartz-manager-web-showcase/deploy/dev.yaml
gcloud deploy apply --file ./quartz-manager-parent/quartz-manager-web-showcase/deploy/staging.yaml
gcloud deploy apply --file ./quartz-manager-parent/quartz-manager-web-showcase/deploy/prod.yaml
gcloud deploy releases create rel-${SHORT_SHA} \
--delivery-pipeline quartz-manager-pipeline \
--description "$(git log -1 --pretty='%s')" \
--build-artifacts /workspace/artifacts.json \
--verbosity=debug \
--annotations "commit_ui=https://source.cloud.google.com/$PROJECT_ID/quartz-manager-standalone/+/$COMMIT_SHA"
artifacts:
objects:
location: 'gs://$PROJECT_ID-gcdeploy-artifacts/'
paths:
- '/workspace/artifacts.json'
options:
logging: CLOUD_LOGGING_ONLY

View File

@@ -5,8 +5,8 @@
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.
last 2 Chrome versions
last 2 Firefox versions
last 2 Edge versions
last 2 Safari versions
last 2 iOS versions

View File

@@ -28,20 +28,12 @@ Happy linting! 💖
"plugins": [
"eslint-plugin-import",
"@angular-eslint/eslint-plugin",
"@typescript-eslint",
"@typescript-eslint/tslint"
"@typescript-eslint"
],
"root": true,
"rules": {
"@angular-eslint/component-class-suffix": "error",
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"@angular-eslint/component-class-suffix": "off",
"@angular-eslint/component-selector": "off",
"@angular-eslint/directive-class-suffix": "error",
"@angular-eslint/directive-selector": [
"error",
@@ -51,7 +43,6 @@ Happy linting! 💖
"style": "camelCase"
}
],
"@angular-eslint/no-host-metadata-property": "error",
"@angular-eslint/no-input-rename": "error",
"@angular-eslint/no-inputs-metadata-property": "error",
"@angular-eslint/no-output-rename": "error",
@@ -80,19 +71,8 @@ Happy linting! 💖
}
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "variable",
"format": [
"camelCase",
"UPPER_CASE"
],
"leadingUnderscore": "forbid",
"trailingUnderscore": "forbid"
}
],
"@typescript-eslint/member-ordering": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-inferrable-types": [
@@ -109,26 +89,10 @@ Happy linting! 💖
],
"@typescript-eslint/no-unused-expressions": "error",
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/quotes": [
"error",
"single"
],
"@typescript-eslint/semi": [
"off",
null
],
"@typescript-eslint/tslint/config": [
"error",
{
"rules": {
"import-spacing": true,
"invoke-injectable": true,
"no-access-missing-member": true,
"templates-use-public": true,
"whitespace": true
}
}
],
"@typescript-eslint/type-annotation-spacing": "off",
"@typescript-eslint/unified-signatures": "error",
"brace-style": [

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,18 +19,27 @@
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"allowedCommonJsDependencies": [
"stompjs", "sockjs-client", "moment"
"@stomp/stompjs", "stompjs", "sockjs-client", "angular2-uuid"
],
"assets": [
"src/assets",
"src/favicon.ico"
],
"styles": [
"src/styles.css"
"src/styles.css",
"node_modules/roboto-fontface/css/roboto/roboto-fontface.css"
],
"scripts": []
},
"configurations": {
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"production": {
"budgets": [
{
@@ -58,18 +67,18 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "angular-spring-starter:build"
"buildTarget": "quartz-manager-ui:build:development"
},
"configurations": {
"production": {
"browserTarget": "angular-spring-starter:build:production"
"buildTarget": "quartz-manager-ui:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "angular-spring-starter:build"
"buildTarget": "quartz-manager-ui:build"
}
},
"lint": {
@@ -80,38 +89,35 @@
}
}
}
},
"angular-spring-starter-e2e": {
"root": "e2e",
"sourceRoot": "e2e",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "./protractor.conf.js",
"devServerTarget": "angular-spring-starter:serve"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"e2e/tsconfig.e2e.json"
],
"exclude": []
}
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"prefix": "qrzmng",
"style": "css"
"style": "css",
"type": "component"
},
"@schematics/angular:directive": {
"prefix": "qrzmng"
"prefix": "qrzmng",
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
},
"cli": {

View File

@@ -0,0 +1,30 @@
import sonarjs from 'eslint-plugin-sonarjs';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module'
}
},
plugins: {
sonarjs
},
rules: {
...sonarjs.configs.recommended.rules,
'sonarjs/deprecation': 'off',
'sonarjs/no-commented-code': 'off',
'sonarjs/no-dead-store': 'off',
'sonarjs/no-incomplete-assertions': 'off',
'sonarjs/no-primitive-wrappers': 'off',
'sonarjs/no-unused-vars': 'off',
'sonarjs/prefer-promise-shorthand': 'off',
'sonarjs/todo-tag': 'off',
'sonarjs/unused-import': 'off'
}
}
];

View File

@@ -0,0 +1,20 @@
const {createEsmPreset} = require('jest-preset-angular/presets');
module.exports = {
...createEsmPreset({
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$'
}),
moduleNameMapper: {
'^tslib$': '<rootDir>/node_modules/tslib/tslib.es6.mjs',
'^rxjs$': '<rootDir>/node_modules/rxjs/dist/cjs/index.js',
'^rxjs/operators$': '<rootDir>/node_modules/rxjs/dist/cjs/operators/index.js',
'^rxjs/(.*)$': '<rootDir>/node_modules/rxjs/dist/cjs/$1',
'^@fortawesome/fontawesome$': '<rootDir>/node_modules/@fortawesome/fontawesome/index.js',
'^@fortawesome/fontawesome-free-solid$': '<rootDir>/node_modules/@fortawesome/fontawesome-free-solid/index.js'
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
transformIgnorePatterns: [
'node_modules/(?!(@angular|@fortawesome|@stomp/rx-stomp|@stomp/stompjs|.*\\.mjs$)/)'
]
};

View File

@@ -1 +1,3 @@
import 'jest-preset-angular/setup-jest';
import {setupZoneTestEnv} from 'jest-preset-angular/setup-env/zone/index.mjs';
setupZoneTestEnv();

View File

@@ -1,42 +0,0 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/0.13/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client:{
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
files: [
],
preprocessors: {
},
mime: {
'text/x-typescript': ['ts','tsx']
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ],
fixWebpackSourcePaths: true
},
reporters: config.angularCli && config.angularCli.codeCoverage
? ['progress', 'coverage-istanbul']
: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,85 +6,68 @@
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build --configuration production",
"test": "jest",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"lint": "ng lint",
"e2e": "ng e2e"
"lint:sonar": "eslint -c eslint.sonar.config.mjs \"src/**/*.ts\"",
"lint:sonar:fix": "eslint -c eslint.sonar.config.mjs \"src/**/*.ts\" --fix"
},
"private": true,
"dependencies": {
"@angular-material-components/datetime-picker": "8.0.0",
"@angular-material-components/moment-adapter": "8.0.0",
"@angular/animations": "14.2.12",
"@angular/cdk": "^14.0.1",
"@angular/common": "14.2.12",
"@angular/compiler": "14.2.12",
"@angular/core": "14.2.12",
"@angular/flex-layout": "14.0.0-beta.41",
"@angular/forms": "14.2.12",
"@angular/material": "^14.0.1",
"@angular/platform-browser": "14.2.12",
"@angular/platform-browser-dynamic": "14.2.12",
"@angular/platform-server": "14.2.12",
"@angular/router": "14.2.12",
"@auth0/angular-jwt": "5.1.0",
"@angular/animations": "21.2.12",
"@angular/cdk": "21.2.10",
"@angular/common": "21.2.12",
"@angular/compiler": "21.2.12",
"@angular/core": "21.2.12",
"@angular/forms": "21.2.12",
"@angular/material": "21.2.10",
"@angular/platform-browser": "21.2.12",
"@angular/platform-browser-dynamic": "21.2.12",
"@angular/platform-server": "21.2.12",
"@angular/router": "21.2.12",
"@auth0/angular-jwt": "5.2.0",
"@danielmoncada/angular-datetime-picker": "21.0.0",
"@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",
"core-js": "2.5.1",
"@stomp/rx-stomp": "2.4.0",
"@stomp/stompjs": "^7.2.0",
"hammerjs": "2.0.8",
"moment": "^2.29.1",
"net": "^1.0.2",
"rxjs": "6.5.5",
"roboto-fontface": "^0.10.0",
"rxjs": "^7.8.2",
"sockjs-client": "^1.1.1",
"stompjs": "^2.3.3",
"tslib": "~2.4.1",
"zone.js": "~0.12.0"
"tslib": "^2.8.1",
"uuid": "^13.0.0",
"zone.js": "~0.16.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "14.2.10",
"@angular-devkit/core": "^14.2.10",
"@angular-eslint/builder": "14.4.0",
"@angular-eslint/eslint-plugin": "14.4.0",
"@angular-eslint/eslint-plugin-template": "14.4.0",
"@angular-eslint/schematics": "14.4.0",
"@angular-eslint/template-parser": "14.4.0",
"@angular/cli": "14.2.10",
"@angular/compiler-cli": "14.2.12",
"@angular/language-service": "14.2.12",
"@angular-devkit/build-angular": "^21.2.10",
"@angular-devkit/core": "^21.2.10",
"@angular-eslint/builder": "21.3.1",
"@angular-eslint/eslint-plugin": "21.3.1",
"@angular-eslint/eslint-plugin-template": "21.3.1",
"@angular-eslint/schematics": "21.3.1",
"@angular-eslint/template-parser": "21.3.1",
"@angular/cli": "^21.2.10",
"@angular/compiler-cli": "21.2.12",
"@angular/language-service": "21.2.12",
"@types/hammerjs": "2.0.34",
"@types/jasmine": "2.5.54",
"@types/jasminewd2": "2.0.3",
"@types/jest": "28.1.1",
"@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/eslint-plugin-tslint": "^5.46.0",
"@typescript-eslint/parser": "5.43.0",
"codelyzer": "~6.0.2",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"@types/jasmine": "^5.1.13",
"@types/jest": "^30.0.0",
"@types/node": "^22.13.14",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.2.1",
"jasmine-core": "~4.5.0",
"jasmine-spec-reporter": "~7.0.0",
"jest": "28.1.3",
"jest-preset-angular": "~12.2.3",
"karma": "~6.4.1",
"karma-chrome-launcher": "~3.1.1",
"karma-cli": "2.0.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"eslint-plugin-sonarjs": "^4.0.3",
"jest": "30.4.1",
"jest-environment-jsdom": "^30.2.0",
"jest-preset-angular": "^16.1.5",
"jsdom": "^27.3.0",
"prettier": "^2.8.1",
"prettier-eslint": "^15.0.1",
"protractor": "~7.0.0",
"ts-node": "10.9.1",
"typescript": "4.6.4"
},
"jest": {
"preset": "jest-preset-angular",
"setupFilesAfterEnv": [
"<rootDir>/jest.setup.ts"
]
"typescript": "5.9.3"
}
}

View File

@@ -1,32 +0,0 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} 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;
// Prevent Karma from running prematurely.
__karma__.loaded = function () {};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();

View File

@@ -1,8 +1,11 @@
<div fxLayout="column" fxLayoutAlign="space-between stretch" fxFill>
<app-header fxFlex="0 0 auto"></app-header>
<div class="content" fxFlex="100" fxFill>
<router-outlet></router-outlet>
@if (isOperationsConsoleRoute()) {
<router-outlet></router-outlet>
} @else {
<div class="app-shell flex flex-column justify-space-between h-100">
<app-header class="flex-none"></app-header>
<div class="content flex h-100">
<router-outlet></router-outlet>
</div>
<app-footer class="flex-none"></app-footer>
</div>
<app-footer fxFlex="0 0 auto"></app-footer>
</div>
}

View File

@@ -2,9 +2,10 @@
display: block;
color: rgba(0,0,0,.54);
font-family: Roboto,"Helvetica Neue";
height: 100%;
min-height: 100%;
}
.content {
padding: 20px;
max-height: calc(100vh - 169px);
}

View File

@@ -1,4 +1,4 @@
import {TestBed, async, waitForAsync} from '@angular/core/testing';
import {TestBed, waitForAsync} from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
@@ -42,7 +42,7 @@ describe('AppComponent', () => {
}).compileComponents();
}));
it('should create the app', async(() => {
it('should create the app', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();

View File

@@ -1,15 +1,31 @@
import {Component} from '@angular/core';
import {Component} from '@angular/core';
import {Router} from '@angular/router';
// I remove temporary fontawesome5 and downgrade to fontawesome4
import fontawesome from '@fortawesome/fontawesome';
import solid from '@fortawesome/fontawesome-free-solid/';
fontawesome.library.add(solid);
import fontawesome from '@fortawesome/fontawesome';
import {
faCheckCircle,
faExclamationCircle,
faExclamationTriangle,
faPause,
faPlay,
faTimesCircle
} from '@fortawesome/fontawesome-free-solid';
fontawesome.library.add(faCheckCircle, faExclamationCircle, faExclamationTriangle, faPause, faPlay, faTimesCircle);
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
standalone: false
})
export class AppComponent {
}
export class AppComponent {
constructor(private router: Router) {
}
isOperationsConsoleRoute(): boolean {
const url = this.router.url || '/';
return url === '/' || url.startsWith('/manager');
}
}

View File

@@ -1,7 +1,7 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, APP_INITIALIZER} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import {JWT_OPTIONS, JwtModule} from '@auth0/angular-jwt';
@@ -17,17 +17,14 @@ import {MatToolbarModule} from '@angular/material/toolbar';
import {MatIconModule} from '@angular/material/icon';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatDatepickerModule} from '@angular/material/datepicker';
import {MatSelectModule} from '@angular/material/select';
import {MatListModule} from '@angular/material/list';
import {MatSidenavModule} from '@angular/material/sidenav';
import {MatNativeDateModule} from '@angular/material/core';
import { NgxMatTimepickerModule, NgxMatDatetimePickerModule} from '@angular-material-components/datetime-picker';
import { NgxMatMomentModule } from '@angular-material-components/moment-adapter';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FlexLayoutModule } from '@angular/flex-layout';
import {MatSelectModule} from '@angular/material/select';
import {MatListModule} from '@angular/material/list';
import {MatSidenavModule} from '@angular/material/sidenav';
import {MatDialogModule} from '@angular/material/dialog';
import {OwlDateTimeModule, OwlNativeDateTimeModule} from '@danielmoncada/angular-datetime-picker';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { ManagerComponent } from './views/manager';
@@ -43,7 +40,8 @@ import {
SchedulerControlComponent,
LogsPanelComponent,
ProgressPanelComponent,
TriggerListComponent
TriggerListComponent,
SimpleTriggerConfigComponent
} from './components';
import {
@@ -52,14 +50,14 @@ import {
UserService,
SchedulerService,
ConfigService,
ProgressWebsocketService,
LogsWebsocketService,
getHtmlBaseUrl,
TriggerService
} from './services';
LogsRxWebsocketService,
ProgressRxWebsocketService,
TriggerService,
CalendarService
} 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,121 +65,85 @@ 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: () => {
return apiService.getToken();
},
whitelistedDomains: ['localhost:8080', 'localhost:4200']
allowedDomains: ['localhost:8080', 'localhost:4200']
}
}
@NgModule({
declarations: [
AppComponent,
HeaderComponent,
FooterComponent,
ManagerComponent,
GithubComponent,
LoginComponent,
NotFoundComponent,
AccountMenuComponent,
SimpleTriggerConfigComponent,
SchedulerControlComponent,
LogsPanelComponent,
ProgressPanelComponent,
ForbiddenComponent,
GenericErrorComponent,
TriggerListComponent
],
imports: [
BrowserAnimationsModule,
BrowserModule,
FormsModule,
ReactiveFormsModule,
HttpClientModule,
AppRoutingModule,
JwtModule.forRoot({
jwtOptionsProvider: {
provide: JWT_OPTIONS,
useFactory: jwtOptionsFactory,
deps: [ApiService]
}
}),
MatMenuModule,
MatTooltipModule,
MatButtonModule,
MatChipsModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatToolbarModule,
MatCardModule,
MatListModule,
MatProgressSpinnerModule,
MatProgressBarModule,
MatDatepickerModule, MatNativeDateModule,
NgxMatMomentModule,
NgxMatDatetimePickerModule,
MatSidenavModule,
FlexLayoutModule
],
providers: [
{
provide: APP_BASE_HREF,
useValue: getHtmlBaseUrl()
},
{
'provide': APP_INITIALIZER,
'useFactory': initUserFactory,
'deps': [UserService],
'multi': true
},
LoginGuard,
GuestGuard,
AdminGuard,
SchedulerService,
JobService,
TriggerService,
ProgressWebsocketService,
LogsWebsocketService,
AuthService,
ApiService,
UserService,
ConfigService,
MatIconRegistry
// StompService,
// ServerSocket
// {
// provide: StompConfig,
// useValue: stompConfig
// }
],
bootstrap: [AppComponent]
})
@NgModule({ declarations: [
AppComponent,
HeaderComponent,
FooterComponent,
ManagerComponent,
GithubComponent,
LoginComponent,
NotFoundComponent,
AccountMenuComponent,
SimpleTriggerConfigComponent,
SchedulerControlComponent,
LogsPanelComponent,
ProgressPanelComponent,
ForbiddenComponent,
GenericErrorComponent,
TriggerListComponent
],
bootstrap: [AppComponent], imports: [BrowserAnimationsModule,
BrowserModule,
FormsModule,
ReactiveFormsModule,
AppRoutingModule,
JwtModule.forRoot({
jwtOptionsProvider: {
provide: JWT_OPTIONS,
useFactory: jwtOptionsFactory,
deps: [ApiService]
}
}),
MatDialogModule,
MatMenuModule,
MatTooltipModule,
MatButtonModule,
MatChipsModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatToolbarModule,
MatCardModule,
MatListModule,
MatProgressSpinnerModule,
MatProgressBarModule,
OwlDateTimeModule,
OwlNativeDateTimeModule,
MatSidenavModule,
], providers: [
{
provide: APP_BASE_HREF,
useValue: getHtmlBaseUrl()
},
{
'provide': APP_INITIALIZER,
'useFactory': initUserFactory,
'deps': [UserService],
'multi': true
},
LoginGuard,
GuestGuard,
AdminGuard,
SchedulerService,
JobService,
TriggerService,
CalendarService,
ProgressRxWebsocketService,
LogsRxWebsocketService,
AuthService,
ApiService,
UserService,
ConfigService,
MatIconRegistry,
provideHttpClient(withInterceptorsFromDi())
] })
export class AppModule { }

View File

@@ -1,8 +1,8 @@
<mat-toolbar id="footer" style="color: rgba(255, 255, 255, 0.541176);" fxLayout="row" fxLayoutAlign="center center">
<a mat-icon-button href="https://github.com/fabioformosa/quartz-manager">
<img src="assets/image/github.png"/>
&nbsp; Quartz Manager
</a>
<!-- Hand crafted with love by &nbsp;-->
<!-- <a href="https://github.com/fabioformosa" style="color: rgba(255, 255, 255, 0.870588);">Fabio Formosa</a>-->
<mat-toolbar id="footer" class="flex flex-row justify-center align-items-center" style="color: rgba(255, 255, 255, 0.541176);">
<a href="https://github.com/fabioformosa/quartz-manager" class="flex flex-row align-items-center" style="gap: 6px">
<div class="flex"><img src="assets/image/github.png"/></div>
<div class="font-size-14 font-weight-500 display-block line-height-100">Quartz Manager</div>
</a>
<!-- Hand crafted with love by &nbsp;-->
<!-- <a href="https://github.com/fabioformosa" style="color: rgba(255, 255, 255, 0.870588);">Fabio Formosa</a>-->
</mat-toolbar>

View File

@@ -1,9 +1,10 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'],
standalone: false
})
export class FooterComponent implements OnInit {

View File

@@ -1,9 +1,10 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-github',
templateUrl: './github.component.html',
styleUrls: ['./github.component.scss']
selector: 'app-github',
templateUrl: './github.component.html',
styleUrls: ['./github.component.scss'],
standalone: false
})
export class GithubComponent implements OnInit {

View File

@@ -7,9 +7,10 @@ import {
import { Router } from '@angular/router';
@Component({
selector: 'app-account-menu',
templateUrl: './account-menu.component.html',
styleUrls: ['./account-menu.component.scss']
selector: 'app-account-menu',
templateUrl: './account-menu.component.html',
styleUrls: ['./account-menu.component.scss'],
standalone: false
})
export class AccountMenuComponent implements OnInit {

View File

@@ -1,35 +1,39 @@
<mat-toolbar color="primary" class="app-navbar">
<button mat-button mat-ripple routerLink="/">
<!-- <img alt="Quartz Manager" class="app-angular-logo" src="assets/image/angular-white-transparent.svg">-->
<span>Quartz Manager</span>
</button>
<div class="right">
<div fxFlex="1 1 auto" fxLayout="row" fxLayoutAlign="flex-end center">
<button *ngIf="!hasSignedIn() && !noAuthenticationRequired()" routerLink="/login" mat-button mat-ripple>
<span>Login</span>
</button>
<button
class="greeting-button"
*ngIf="hasSignedIn() && !noAuthenticationRequired()"
mat-button mat-ripple
[matMenuTriggerFor]="accountMenu">
<span>Hi, {{userName()}}</span>
</button>
<button
class="greeting-hamburger"
*ngIf="hasSignedIn()"
mat-icon-button mat-ripple
[matMenuTriggerFor]="accountMenu">
<mat-icon>menu</mat-icon>
</button>
<mat-menu #accountMenu
class="app-header-accountMenu"
yposition="below"
[overlapTrigger]="false">
<app-account-menu ></app-account-menu>
</mat-menu>
</div>
</div>
</mat-toolbar>
<mat-toolbar color="primary" class="app-navbar">
<button mat-button mat-ripple routerLink="/">
<!-- <img alt="Quartz Manager" class="app-angular-logo" src="assets/image/angular-white-transparent.svg">-->
<span>Quartz Manager</span>
</button>
<div class="right">
<div class="flex flex-row flex-1 justify-flex-end align-items-center">
@if (!hasSignedIn() && !noAuthenticationRequired()) {
<button routerLink="/login" mat-button mat-ripple>
<span>Login</span>
</button>
} @if (hasSignedIn() && !noAuthenticationRequired()) {
<button
class="greeting-button"
mat-button
mat-ripple
[matMenuTriggerFor]="accountMenu">
<span>Hi, {{ userName() }}</span>
</button>
} @if (hasSignedIn()) {
<button
class="greeting-hamburger"
mat-icon-button
mat-ripple
[matMenuTriggerFor]="accountMenu">
<mat-icon>menu</mat-icon>
</button>
}
<mat-menu
#accountMenu
class="app-header-accountMenu"
yposition="below"
[overlapTrigger]="false">
<app-account-menu></app-account-menu>
</mat-menu>
</div>
</div>
</mat-toolbar>

View File

@@ -6,10 +6,11 @@ import {
} from '../../services';
import { Router } from '@angular/router';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
standalone: false
})
export class HeaderComponent implements OnInit {

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

@@ -1,35 +1,67 @@
<mat-card fxFlex="1 1 auto">
<mat-card-header fxLayout="row" fxLayoutAlign="space-between none" style="padding-right: 1em;">
<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
*ngFor = "let log of logs; let first = first" fxLayout="row" fxLayout.xs="column" fxLayoutAlign="start" fxLayoutGap="10px">
<div fxFlex="0 1 300px">
<span [ngClass]="{'animate__animated animate__zoomIn zoomIn firstLog': first}"> [{{log.time|date:'medium'}}]</span>
</div>
<div fxFlex="1 1 16px">
<span [ngClass]="{'animated zoomIn firstLog': first}">
<i class = "fas" [ngClass]="{'fa-check-circle green': log.type == 'INFO',
'fa-exclamation-triangle yellow': log.type == 'WARN',
'fa-times-circle red': log.type == 'ERROR'}"></i>
</span>
</div>
<div fxFlex="0 1 250px">
<span [ngClass]="{'animate__animated animate__zoomIn zoomIn firstLog': first}">
{{log.threadName}}
</span>
</div>
<div fxFlex="1 1">
<span [ngClass]="{'animate__animated animate__zoomIn zoomIn firstLog': first}"> {{log.msg}}</span>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="flex flex-1 max-h-100">
<mat-card-header class="pb-16">
<mat-card-subtitle><b>JOB LOGS</b></mat-card-subtitle>
</mat-card-header>
<mat-card-content class="flex flex-1 overflow-y-auto">
<div class="flex-1">
@if (!selectedTriggerName && (!logs || logs.length == 0)) {
<div class="h-100 w-100" style="text-align: center">
<img
src="assets/image/logs.svg"
alt="no logs"
width="320"
style="margin-top: 6em" />
</div>
} @if (isWaitingForLogs()) {
<div
class="waitingLogs flex flex-column align-items-center justify-center gap-12">
<mat-spinner diameter="36"></mat-spinner>
<div>Waiting for logs from {{ selectedTriggerName }}...</div>
</div>
}
<div id="logs" class="w-100" style="height: 100%">
@for (log of logs; track log; let first = $first) {
<div
class="log-row flex flex-row gap-10">
<div style="flex: 1; max-width: 300px">
<span
[ngClass]="{
'animate__animated animate__zoomIn zoomIn firstLog': first
}">
[{{ log.time | date : 'medium' }}]</span
>
</div>
<div style="flex: 1; max-width: 16px">
<span [ngClass]="{ 'animated zoomIn firstLog': first }">
<i
class="fas"
[ngClass]="{
'fa-check-circle green': log.type == 'INFO',
'fa-exclamation-triangle yellow': log.type == 'WARN',
'fa-times-circle red': log.type == 'ERROR'
}"></i>
</span>
</div>
<div style="flex: 1; max-width: 250px">
<span
[ngClass]="{
'animate__animated animate__zoomIn zoomIn firstLog': first
}">
{{ log.threadName }}
</span>
</div>
<div style="flex: 1">
<span
[ngClass]="{
'animate__animated animate__zoomIn zoomIn firstLog': first
}">
{{ log.msg }}</span
>
</div>
</div>
}
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -1,3 +1,9 @@
:host {
flex: 1;
display: flex;
flex-direction: column;
}
.red{
color: red;
}
@@ -9,11 +15,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,118 @@
import {Subject} from 'rxjs';
import {LogsPanelComponent} from './logs-panel.component';
import {TriggerKey} from '../../model/triggerKey.model';
import {jest} from '@jest/globals';
describe('LogsPanelComponent', () => {
const ngZone = {run: jest.fn((fn: () => void) => fn())};
beforeEach(() => ngZone.run.mockClear());
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, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null);
expect(logsRxWebsocketService.watch.mock.calls[0]).toEqual(['/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(ngZone.run).toHaveBeenCalled();
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, ngZone 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(logsRxWebsocketService.watch.mock.calls[1]).toEqual(['/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, ngZone as any);
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, ngZone as any);
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, ngZone as any);
expect(() => component.ngOnDestroy()).not.toThrow();
});
});

View File

@@ -1,42 +1,86 @@
import {Component, OnInit, Input, Output, EventEmitter} from '@angular/core';
import {Component, Input, NgZone, OnDestroy, OnInit} from '@angular/core';
import {LogsWebsocketService, ApiService} from '../../services';
import {Observable} from 'rxjs';
import {ApiService} from '../../services';
import {LogsRxWebsocketService} from '../../services/logs.rx-websocket.service';
import {map} from 'rxjs/operators';
import {TriggerKey} from '../../model/triggerKey.model';
@Component({
selector: 'logs-panel',
templateUrl: './logs-panel.component.html',
styleUrls: ['./logs-panel.component.scss']
@Component({
selector: 'logs-panel',
templateUrl: './logs-panel.component.html',
styleUrls: ['./logs-panel.component.scss'],
standalone: false
})
export class LogsPanelComponent implements OnInit {
export class LogsPanelComponent implements OnInit, OnDestroy {
MAX_LOGS = 30;
logs = new Array();
logs = new Array();
selectedTriggerName: string;
constructor(
private logsWebsocketService: LogsWebsocketService,
private apiService: ApiService
) {
}
topicSubscription;
private selectedTriggerKey: TriggerKey;
constructor(
private logsRxWebsocketService: LogsRxWebsocketService,
private apiService: ApiService,
private ngZone: NgZone
) {
}
@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(logRecord => this.ngZone.run(() => this._showNewLog(logRecord)), (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

@@ -1,43 +1,72 @@
<!-- <div class="progress" [hidden]="progress.percentage < 0">
<div class="progress-bar"
role="progressbar"
[ngStyle]="{width: percentageStr}">
{{percentageStr}}
</div>
</div> -->
<mat-card style="padding-bottom: 0">
<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">
<mat-progress-bar mode="determinate" value="{{progress.percentage}}"></mat-progress-bar>
{{percentageStr}}
</div>
<div id="counterBox" fxLayout="row" fxLayoutAlign="center" *ngIf="progress.timesTriggered">
<span id="timesTriggeredCounter" class="animated pulse">{{progress.timesTriggered}}</span>
<span id="totCounter" *ngIf="progress.repeatCount > 0">&nbsp;/&nbsp;{{progress.repeatCount}} </span>
</div>
<mat-divider *ngIf="progress.timesTriggered"></mat-divider>
<div fxLayout="row" fxLayoutAlign="space-around center">
<div class="fireBox">
<div class="fireBoxHeader">prev fire time</div>
<div class="fireBoxContent"><span class="animated pulse">{{progress.previousFireTime|date:'dd-MM-yyyy HH:mm:ss'}}</span></div>
<div class="fireBoxContent" [hidden]="progress.previousFireTime"><span>-</span></div>
</div>
<div class="fireBox">
<div class="fireBoxHeader">next fire time</div>
<div class="fireBoxContent"><span class="animated pulse">{{progress.nextFireTime|date:'dd-MM-yyyy HH:mm:ss'}}</span></div>
<div class="fireBoxContent" [hidden]="progress.nextFireTime"><span>-</span></div>
</div>
<div class="fireBox">
<div class="fireBoxHeader">final fire time</div>
<div class="fireBoxContent"><span class="animated pulse">{{progress.finalFireTime|date:'dd-MM-yyyy HH:mm:ss'}}</span></div>
<div class="fireBoxContent" [hidden]="progress.finalFireTime"><span>-</span></div>
</div>
</div>
</mat-card-content>
</mat-card>
<!-- <div class="progress" [hidden]="progress.percentage < 0">
<div class="progress-bar"
role="progressbar"
[ngStyle]="{width: percentageStr}">
{{percentageStr}}
</div>
</div> -->
<mat-card
style="padding-bottom: 0"
[ngClass]="{ 'progress-updated': progressUpdated }">
<mat-card-header style="padding-bottom: 16px">
<mat-card-subtitle><b>JOB PROGRESS</b></mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@if (progress.percentage !== -1) {
<div id="progressBarBox">
<mat-progress-bar
mode="determinate"
value="{{ progress.percentage }}"></mat-progress-bar>
{{ percentageStr }}
</div>
} @if (progress.timesTriggered) {
<div id="counterBox" class="flex flex-row justify-center">
<span id="timesTriggeredCounter" class="animated pulse">{{
progress.timesTriggered
}}</span>
@if (progress.repeatCount > 0) {
<span id="totCounter">&nbsp;/&nbsp;{{ progress.repeatCount }} </span>
}
</div>
} @if (progress.timesTriggered) {
<mat-divider></mat-divider>
}
<div class="flex flex-row align-items-center justify-space-around">
<div class="fireBox">
<div class="fireBoxHeader">prev fire time</div>
<div class="fireBoxContent">
<span class="animated pulse">{{
progress.previousFireTime | date : 'dd-MM-yyyy HH:mm:ss'
}}</span>
</div>
<div class="fireBoxContent" [hidden]="progress.previousFireTime">
<span>-</span>
</div>
</div>
<div class="fireBox">
<div class="fireBoxHeader">next fire time</div>
<div class="fireBoxContent">
<span class="animated pulse">{{
progress.nextFireTime | date : 'dd-MM-yyyy HH:mm:ss'
}}</span>
</div>
<div class="fireBoxContent" [hidden]="progress.nextFireTime">
<span>-</span>
</div>
</div>
<div class="fireBox">
<div class="fireBoxHeader">final fire time</div>
<div class="fireBoxContent">
<span class="animated pulse">{{
progress.finalFireTime | date : 'dd-MM-yyyy HH:mm:ss'
}}</span>
</div>
<div class="fireBoxContent" [hidden]="progress.finalFireTime">
<span>-</span>
</div>
</div>
</div>
</mat-card-content>
</mat-card>

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,107 @@
import {Subject} from 'rxjs';
import {ProgressPanelComponent} from './progress-panel.component';
import {TriggerKey} from '../../model/triggerKey.model';
import {jest} from '@jest/globals';
describe('ProgressPanelComponent', () => {
const ngZone = {run: jest.fn((fn: () => void) => fn())};
beforeEach(() => ngZone.run.mockClear());
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, ngZone as any);
component.triggerKey = new TriggerKey('trigger-1', null);
expect(progressRxWebsocketService.watch.mock.calls[0]).toEqual(['/topic/progress/trigger-1']);
messages.next({body: JSON.stringify({percentage: 75, timesTriggered: 3})});
jest.runOnlyPendingTimers();
expect(ngZone.run).toHaveBeenCalled();
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, ngZone 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.mock.calls[1]).toEqual(['/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, ngZone 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, ngZone 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, ngZone as any);
expect(() => component.ngOnDestroy()).not.toThrow();
});
});

View File

@@ -1,84 +1,93 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'
import {ProgressWebsocketService, QuartzManagerWebsocketMessage} from '../../services';
import { Observable } from 'rxjs';
import {Component, Input, NgZone, OnDestroy, OnInit} from '@angular/core'
import TriggerFiredBundle from '../../model/trigger-fired-bundle.model';
// import {Message} from '@stomp/stompjs';
import {TriggerKey} from '../../model/triggerKey.model';
import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.service';
import {map} from 'rxjs/operators';
// 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'
@Component({
selector: 'progress-panel',
templateUrl: './progress-panel.component.html',
styleUrls: ['./progress-panel.component.scss']
@Component({
selector: 'progress-panel',
templateUrl: './progress-panel.component.html',
styleUrls: ['./progress-panel.component.scss'],
standalone: false
})
export class ProgressPanelComponent implements OnInit {
export class ProgressPanelComponent implements OnInit, OnDestroy {
progress: TriggerFiredBundle = ProgressPanelComponent._buildEmptyProgress();
percentageStr: string;
progressUpdated = false;
progress: TriggerFiredBundle = new TriggerFiredBundle();
percentageStr: string;
topicSubscription;
private selectedTriggerKey: TriggerKey;
// // Stream of messages
// private subscription: Subscription;
// public messages: Observable<Message>;
// // Subscription status
// public subscribed: boolean;
// // Array of historic message (bodies)
// public mq: Array<string> = [];
constructor(
private progressRxWebsocketService: ProgressRxWebsocketService,
private ngZone: NgZone
) { }
@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(progress => this.ngZone.run(() => this.onNewProgressMsg(progress)), (err) => {
console.log(err);
// TODO in case of 401
// this.apiService.get('/quartz-manager/session/refresh');
});
};
constructor(
private progressWebsocketService: ProgressWebsocketService,
// private _stompService: StompService,
// private serverSocket : ServerSocket
) { }
onNewProgressMsg = (receivedMsg: QuartzManagerWebsocketMessage) => {
if (receivedMsg.type === 'SUCCESS') {
const newStatus = receivedMsg.message;
this.progress = newStatus;
this.percentageStr = this.progress.percentage + '%';
}
}
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

@@ -1,26 +1,40 @@
<mat-card>
<mat-card-content>
<div fxLayout="row" fxLayoutAlign="left stretch" fxLayoutGap="30px">
<button id="schedulerControllerBtn" mat-raised-button class="btn btn-default large-btn" (click)="startOrPause()">
<span *ngIf = "scheduler?.status === 'RUNNING'">
<i class="fas fa-pause red"></i>
</span>
<span *ngIf = "scheduler?.status === 'STOPPED' || scheduler?.status === 'PAUSED'">
<i class="fas fa-play green"></i>
</span>
</button>
<div fxLayout="column center">
<mat-card-subtitle style="margin: auto;"><b>SCHEDULER</b></mat-card-subtitle>
</div>
<mat-divider [vertical]="true"></mat-divider>
<div fxLayout="column">
<div><label>Name</label></div>
<div><span id="scheduler-name">{{scheduler?.name}}</span></div>
</div>
<div fxLayout="column">
<div><label>Instance ID</label></div>
<div><span id="scheduler-instance">{{scheduler?.instanceId}}</span></div>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-content>
<div class="flex flex-row align-items-stretch gap-30">
<button
id="schedulerControllerBtn"
mat-raised-button
class="btn btn-default large-btn"
(click)="startOrPause()">
@if (scheduler?.status === 'RUNNING') {
<span>
<i class="fas fa-pause red"></i>
</span>
} @if (scheduler?.status === 'STOPPED' || scheduler?.status ===
'PAUSED') {
<span>
<i class="fas fa-play green"></i>
</span>
}
</button>
<div class="flex flex-column align-items-center">
<mat-card-subtitle style="margin: auto"
><b>SCHEDULER</b></mat-card-subtitle
>
</div>
<mat-divider [vertical]="true"></mat-divider>
<div class="flex flex-column justify-space-between">
<div><label>Name</label></div>
<div>
<span id="scheduler-name">{{ scheduler?.name }}</span>
</div>
</div>
<div class="flex flex-column justify-space-between">
<div><label>Instance ID</label></div>
<div>
<span id="scheduler-instance">{{ scheduler?.instanceId }}</span>
</div>
</div>
</div>
</mat-card-content>
</mat-card>

View File

@@ -11,7 +11,12 @@ label{
font-size: smaller;
}
#scheduler-name{
text-transform: capitalize;
font-size: larger;
}
#scheduler-name{
text-transform: capitalize;
font-size: 14px;
}
#scheduler-instance {
text-transform: capitalize;
font-size: 14px;
}

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,23 +62,23 @@ 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();
schedulerBtnDe.nativeElement.click();
const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/run');
const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/start');
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,23 +86,23 @@ 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();
schedulerBtnDe.nativeElement.click();
const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/pause');
const startSchedulerReq = httpTestingController.expectOne('/quartz-manager/scheduler/standby');
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

@@ -2,10 +2,11 @@ import {Component, OnInit} from '@angular/core';
import {SchedulerService, UserService} from '../../services';
import {Scheduler} from '../../model/scheduler.model';
@Component({
selector: 'qrzmng-scheduler-control',
templateUrl: './scheduler-control.component.html',
styleUrls: ['./scheduler-control.component.scss']
@Component({
selector: 'qrzmng-scheduler-control',
templateUrl: './scheduler-control.component.html',
styleUrls: ['./scheduler-control.component.scss'],
standalone: false
})
export class SchedulerControlComponent implements OnInit {
@@ -34,16 +35,16 @@ export class SchedulerControlComponent implements OnInit {
});
};
stopScheduler = function () {
this.schedulerService.stopScheduler().subscribe((res) => {
this.scheduler.status = 'STOPPED'
stopScheduler = function () {
this.schedulerService.shutdownScheduler().subscribe((res) => {
this.scheduler.status = 'STOPPED'
}, (res) => {
console.log(JSON.stringify(res))
});
};
pauseScheduler = function () {
this.schedulerService.pauseScheduler().subscribe((res) => {
pauseScheduler = function () {
this.schedulerService.standbyScheduler().subscribe((res) => {
this.scheduler.status = 'PAUSED'
}, (res) => {
console.log(JSON.stringify(res))

View File

@@ -1,189 +1,257 @@
<mat-card fxFlex="1 1 auto">
<mat-card-header>
<mat-card class="trigger-config-card">
<mat-card-header style="padding-bottom: 16px">
<mat-card-subtitle><b>TRIGGER DETAILS</b></mat-card-subtitle>
</mat-card-header>
<mat-divider></mat-divider>
<mat-card-content *ngIf="shouldShowTheTriggerCardContent()" style="position: relative; height: 100%">
<div fxLayout="column" style="overflow-y: auto; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
overflow: auto;height: calc(100% - 3em); padding-top: 1em;">
<mat-card id="noEligibleJobsAlert" *ngIf="jobs?.length === 0" style="background-color: #ff6385">
@if (shouldShowTheTriggerCardContent()) {
<mat-card-content class="trigger-config-content">
<div
class="flex flex-column"
style="
overflow-y: auto;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: auto;
padding: 1em;
">
@if (jobs?.length === 0) {
<mat-card id="noEligibleJobsAlert" style="background-color: #ff6385">
<mat-card-content>
<i class="fas fa-exclamation-circle" style="color: #fff"></i>&nbsp;<strong>WARNING</strong>
Not found any eligible job classes for quartz-manager! <br/>
<p style="font-size: 0.8em">Please, make sure you have extended <i>AbstractQuartzManagerJob</i> and set the
app prop <i>quartz-manager.jobClassPackages</i> with the correct java package </p>
<i class="fas fa-exclamation-circle" style="color: #fff"></i
>&nbsp;<strong>WARNING</strong> Not found any eligible job classes for
quartz-manager! <br />
<p style="font-size: 0.8em">
Please, make sure you have extended
<i>AbstractQuartzManagerJob</i> and set the app prop
<i>quartz-manager.jobClassPackages</i> with the correct java package
</p>
</mat-card-content>
</mat-card>
<form name="triggerConfigForm" fxFlex="1 1 100%"
[formGroup]="simpleTriggerReactiveForm" (ngSubmit)="onSubmitTriggerConfig()">
}
<form
name="triggerConfigForm"
class="trigger-config-form"
class="flex-1"
[formGroup]="simpleTriggerReactiveForm"
(ngSubmit)="onSubmitTriggerConfig()">
<div>
<mat-form-field
[appearance]="enabledTriggerForm && !trigger ? 'standard': 'none'"
class="full-size-input">
<mat-form-field class="full-size-input">
<mat-label>Trigger Name</mat-label>
<input id="triggerName"
[readonly]="!enabledTriggerForm || trigger"
matInput placeholder="name of the trigger (unique)"
formControlName="triggerName" name="triggerName">
<mat-error *ngIf="simpleTriggerReactiveForm.controls.triggerName.errors?.required">
Name is <strong>required</strong>
</mat-error>
<input
id="triggerName"
matInput
placeholder="name of the trigger (unique)"
formControlName="triggerName"
name="triggerName" />
@if
(simpleTriggerReactiveForm.controls.triggerName.errors?.required) {
<mat-error> Name is <strong>required</strong> </mat-error>
}
</mat-form-field>
</div>
<div>
<mat-form-field
[appearance]="enabledTriggerForm ? 'standard': 'none'"
class="full-size-input"
>
<mat-form-field class="full-size-input">
<mat-label>Job Class</mat-label>
<mat-select id="jobClass" name="jobClass" formControlName="jobClass" [disabled]="!enabledTriggerForm">
<mat-option *ngFor="let job of jobs" [value]="job" style="font-size: 0.8em">
{{job}}
<mat-select
id="jobClass"
name="jobClass"
formControlName="jobClass">
@for (job of jobs; track job) {
<mat-option [value]="job" class="font-13">
{{ job }}
</mat-option>
}
</mat-select>
<mat-error *ngIf="simpleTriggerReactiveForm.controls.jobClass.errors?.required">
Job is <strong>required</strong>
</mat-error>
@if (simpleTriggerReactiveForm.controls.jobClass.errors?.required) {
<mat-error> Job is <strong>required</strong> </mat-error>
}
</mat-form-field>
</div>
<div>
<mat-form-field
[appearance]="enabledTriggerForm ? 'standard': 'none'"
class="full-size-input"
>
<mat-form-field class="full-size-input">
<mat-label>Misfire Instruction</mat-label>
<mat-select id="misfireInstruction" name="misfireInstruction" formControlName="misfireInstruction"
[disabled]="!enabledTriggerForm" style="font-size: 0.8em">
<mat-option value="MISFIRE_INSTRUCTION_FIRE_NOW">FIRE NOW</mat-option>
<mat-option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE NOW WITH
EXISTING REPEAT COUNT
<mat-select
id="misfireInstruction"
name="misfireInstruction"
formControlName="misfireInstruction">
<mat-option class="font-13" value="MISFIRE_INSTRUCTION_FIRE_NOW"
>FIRE NOW</mat-option
>
<mat-option
class="font-13"
value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT"
>RESCHEDULE NOW WITH EXISTING REPEAT COUNT
</mat-option>
<mat-option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE NOW WITH
REMAINING REPEAT COUNT
<mat-option
class="font-13"
value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT"
>RESCHEDULE NOW WITH REMAINING REPEAT COUNT
</mat-option>
<mat-option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE NEXT WITH
REMAINING COUNT
<mat-option
class="font-13"
value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT"
>RESCHEDULE NEXT WITH REMAINING COUNT
</mat-option>
<mat-option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE NEXT WITH EXISTING
COUNT
<mat-option
class="font-13"
value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT"
>RESCHEDULE NEXT WITH EXISTING COUNT
</mat-option>
</mat-select>
<mat-error *ngIf="simpleTriggerReactiveForm.controls.misfireInstruction.errors?.required">
@if
(simpleTriggerReactiveForm.controls.misfireInstruction.errors?.required)
{
<mat-error>
The misfire instruction is <strong>required</strong>
</mat-error>
}
</mat-form-field>
<div class="small" [innerHTML]="getMisfireInstructionCaption()"></div>
</div>
<br/>
<br />
<div formGroupName="triggerPeriod">
<div>
<mat-form-field
[appearance]="enabledTriggerForm ? 'standard': 'none'"
class="full-size-input"
>
<mat-form-field class="full-size-input">
<mat-label>Start Date (optional)</mat-label>
<input id="startDate"
[readonly]="!enabledTriggerForm"
matInput
[ngxMatDatetimePicker]="startDatePicker" placeholder="Choose a start date"
formControlName="startDate" name="startDate">
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<ngx-mat-datetime-picker #startDatePicker showSpinners="true" showSeconds="true">
</ngx-mat-datetime-picker>
<input
id="startDate"
matInput
[owlDateTime]="startDatePicker"
[owlDateTimeTrigger]="startDatePicker"
placeholder="Choose a start date"
formControlName="startDate"
name="startDate" />
<button
type="button"
class="datetime-picker-trigger"
mat-icon-button
matSuffix
[owlDateTimeTrigger]="startDatePicker">
<mat-icon>event</mat-icon>
</button>
<owl-date-time
#startDatePicker
[showSecondsTimer]="true">
</owl-date-time>
</mat-form-field>
</div>
<div>
<mat-form-field
[appearance]="enabledTriggerForm ? 'standard': 'none'"
class="full-size-input"
>
<mat-form-field class="full-size-input">
<mat-label>End Date (optional)</mat-label>
<input id="endDate"
[readonly]="!enabledTriggerForm"
matInput
[ngxMatDatetimePicker]="endDatePicker" placeholder="Choose a end date"
formControlName="endDate" name="endDate"
>
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<ngx-mat-datetime-picker #endDatePicker showSpinners="true" showSeconds="true">
</ngx-mat-datetime-picker>
<input
id="endDate"
matInput
[owlDateTime]="endDatePicker"
[owlDateTimeTrigger]="endDatePicker"
placeholder="Choose a end date"
formControlName="endDate"
name="endDate" />
<button
type="button"
class="datetime-picker-trigger"
mat-icon-button
matSuffix
[owlDateTimeTrigger]="endDatePicker">
<mat-icon>event</mat-icon>
</button>
<owl-date-time
#endDatePicker
[showSecondsTimer]="true">
</owl-date-time>
</mat-form-field>
<mat-error *ngIf="simpleTriggerReactiveForm.controls.triggerPeriod.errors?.invalidTriggerPeriod" style="font-size: small">
the end date cannot be <strong>before</strong> the start date
@if
(simpleTriggerReactiveForm.controls.triggerPeriod.errors?.invalidTriggerPeriod)
{
<mat-error style="font-size: small">
the end date cannot be <strong>before</strong> the start date
</mat-error>
}
</div>
</div>
<div formGroupName="triggerRecurrence">
<div>
<mat-form-field
[appearance]="enabledTriggerForm ? 'standard': 'none'"
class="full-size-input"
>
<mat-form-field class="full-size-input">
<mat-label>Repeat Interval [in mills]</mat-label>
<input id="repeatInterval"
[readonly]="!enabledTriggerForm"
matInput placeholder="Repeat Interval [in mills]" type="number"
formControlName="repeatInterval" name="repeatInterval"
>
<mat-error *ngIf="simpleTriggerReactiveForm.controls.triggerRecurrence.errors?.invalidTriggerRecurrence">
repeatCount and repeatInterval must be <strong>both</strong> set or unset
<input
id="repeatInterval"
matInput
placeholder="Repeat Interval [in mills]"
type="number"
formControlName="repeatInterval"
name="repeatInterval" />
@if
(simpleTriggerReactiveForm.controls.triggerRecurrence.errors?.invalidTriggerRecurrence)
{
<mat-error>
repeatCount and repeatInterval must be <strong>both</strong> set
or unset
</mat-error>
}
</mat-form-field>
</div>
<div>
<mat-form-field
[appearance]="enabledTriggerForm ? 'standard': 'none'"
class="full-size-input"
>
<mat-form-field class="full-size-input">
<mat-label>Repeat Count</mat-label>
<input id="repeatCount"
[readonly]="!enabledTriggerForm"
matInput placeholder="Repeat Count (-1 REPEAT INDEFINITELY)" type="number"
formControlName="repeatCount" name="repeatCount"
>
<mat-error *ngIf="simpleTriggerReactiveForm.controls.triggerRecurrence.errors?.invalidTriggerRecurrence">
repeatCount and repeatInterval must be <strong>both</strong> set or unset
<input
id="repeatCount"
matInput
placeholder="Repeat Count (-1 REPEAT INDEFINITELY)"
type="number"
formControlName="repeatCount"
name="repeatCount" />
@if
(simpleTriggerReactiveForm.controls.triggerRecurrence.errors?.invalidTriggerRecurrence)
{
<mat-error>
repeatCount and repeatInterval must be <strong>both</strong> set
or unset
</mat-error>
}
</mat-form-field>
</div>
</div>
<br/>
<div fxLayout="row" fxFlexAlign="space-evenly center" style="padding-bottom: 1em;">
<div fxFlex="1 1 auto" style="text-align: center" *ngIf="enabledTriggerForm">
<button mat-raised-button
type="button"
*ngIf="enabledTriggerForm"
(click)="onResetReactiveForm()">
<br />
<div
class="flex flex-row align-items-center justify-space-evenly"
style="padding-bottom: 1em">
@if (simpleTriggerReactiveForm.enabled) {
<div class="flex-1" style="text-align: center">
<button
mat-raised-button
type="button"
(click)="onResetReactiveForm()">
Cancel
</button>
</div>
<div fxFlex="1 1 auto" style="text-align: center" *ngIf="enabledTriggerForm">
<button mat-raised-button
type="submit" color="primary"
[disabled]="simpleTriggerReactiveForm.invalid"
*ngIf="enabledTriggerForm">
} @if (simpleTriggerReactiveForm.enabled) {
<div class="flex-1" style="text-align: center">
<button
mat-raised-button
type="submit"
color="primary"
[disabled]="simpleTriggerReactiveForm.invalid">
Submit
</button>
</div>
<div fxFlex="1 1 auto" style="text-align: center" *ngIf="!enabledTriggerForm">
<button mat-raised-button type="button"
*ngIf="!enabledTriggerForm"
(click)="enabledTriggerForm = true">
Reschedule
} @if (!simpleTriggerReactiveForm.enabled) {
<div class="flex-1" style="text-align: center">
<button
mat-raised-button
type="button"
(click)="
openTriggerForm();
simpleTriggerReactiveForm.controls['triggerName'].disable()
">
Reschedule
</button>
</div>
}
</div>
</form>
</div>
</mat-card-content>
}
</mat-card>

View File

@@ -1,3 +1,23 @@
:host {
display: flex;
height: 100%;
min-height: 0;
width: 100%;
}
.trigger-config-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.trigger-config-content {
flex: 1;
min-height: 0;
position: relative;
}
.small{
font-size: 0.8em;
}
@@ -5,6 +25,22 @@
.full-size-input{
width: 100%;
}
:host ::ng-deep .trigger-config-form .mat-mdc-form-field {
font-size: 13px;
}
:host ::ng-deep .trigger-config-form .mat-mdc-select-value,
:host ::ng-deep .trigger-config-form .mat-mdc-select-value-text,
:host ::ng-deep .trigger-config-form .mat-mdc-input-element,
:host ::ng-deep .trigger-config-form .mdc-floating-label {
font-size: 13px;
}
:host ::ng-deep .trigger-config-form .mat-mdc-select-trigger {
min-height: 20px;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {

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 {SimpleTriggerConfigComponent} from './simple-trigger-config.component';
import {ApiService, ConfigService, CONTEXT_PATH, SchedulerService} from '../../services';
@@ -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>;
@@ -51,7 +56,7 @@ describe('SimpleTriggerConfig', () => {
it('should fetch no triggers at the init', () => {
expect(component).toBeTruthy();
httpTestingController.expectNone(`${CONTEXT_PATH}/simple-triggers/my-simple-trigger`);
httpTestingController.expectNone(`${CONTEXT_PATH}/simple-triggers/DEFAULT/my-simple-trigger`);
});
function setInputValue(componentDe: DebugElement, inputSelector: string, value: string) {
@@ -81,7 +86,7 @@ describe('SimpleTriggerConfig', () => {
const dropdownDe = componentDe.query(By.css(dropdownSelector));
dropdownDe.nativeElement.click();
fixture.detectChanges();
const matOptionDe = componentDe.query(By.css('.mat-select-panel')).queryAll(By.css('.mat-option'));
const matOptionDe = componentDe.query(By.css('.mat-mdc-select-panel')).queryAll(By.css('.mat-mdc-option'));
matOptionDe[index].nativeElement.click();
fixture.detectChanges();
}
@@ -90,17 +95,17 @@ describe('SimpleTriggerConfig', () => {
component.openTriggerForm();
fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`);
getJobsReq.flush(['TestJob']);
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`);
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);
}
@@ -119,50 +124,54 @@ describe('SimpleTriggerConfig', () => {
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 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;
component.onNewTrigger.subscribe(simpleTrigger => actualNewTrigger = simpleTrigger);
let submittedTriggerKey: TriggerKey;
component.onTriggerSubmitting.subscribe(triggerKey => submittedTriggerKey = triggerKey);
submitButton.nativeElement.click();
expect(submittedTriggerKey).toEqual(new TriggerKey(testTriggerName, null));
flush();
const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`);
const postSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/${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/DEFAULT/${testTriggerName}`);
getSimpleTriggerReq.flush(mockTrigger);
component.simpleTriggerReactiveForm.setValue({
triggerName: 'test-trigger',
jobClass: 'TestJob',
triggerName: testTriggerName,
jobClass: testJobName,
triggerRecurrence: {
repeatInterval: 2000,
repeatCount: 100,
@@ -178,10 +187,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 +198,7 @@ describe('SimpleTriggerConfig', () => {
submitButton.nativeElement.click();
const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/test-trigger`);
const putSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/${testTriggerName}`);
putSimpleTriggerReq.flush(mockTrigger);
expect(actualNewTrigger).toBeUndefined();
@@ -202,16 +211,16 @@ describe('SimpleTriggerConfig', () => {
component.trigger = new SimpleTrigger();
component.trigger.triggerKeyDTO = mockTriggerKey;
fixture.detectChanges();
const mockTrigger = new Trigger();
mockTrigger.triggerKeyDTO = mockTriggerKey;
mockTrigger.jobDetailDTO = <JobDetail>{jobClassName: 'TestJob', description: null};
const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/my-simple-trigger`);
const getSimpleTriggerReq = httpTestingController.expectOne(`${CONTEXT_PATH}/simple-triggers/DEFAULT/my-simple-trigger`);
getSimpleTriggerReq.flush(mockTrigger);
fixture.detectChanges();
const componentDe: DebugElement = fixture.debugElement;
const submitButton = componentDe.query(By.css('form button'));
const submitButton = componentDe.query(By.css('form button:not(.datetime-picker-trigger)'));
expect(submitButton.nativeElement.textContent.trim()).toEqual('Reschedule');
});
@@ -220,13 +229,49 @@ 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/DEFAULT/${testTriggerName}`);
getSimpleTriggerReq.flush(mockTrigger);
expect(component.simpleTriggerReactiveForm.value.triggerName).toEqual(testTriggerName);
component.openNewTriggerForm();
expect(component.simpleTriggerReactiveForm.value.triggerName).toBeNull();
expect(component.simpleTriggerReactiveForm.value.jobClass).toBeNull();
expect(component.shouldShowTheTriggerCardContent()).toBeTruthy();
});
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', () => {
fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`);
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`);
getJobsReq.flush([]);
fixture.detectChanges();
@@ -240,7 +285,7 @@ describe('SimpleTriggerConfig', () => {
it('should not display the warning if there are eligible jobs', () => {
fixture.detectChanges();
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/jobs`);
const getJobsReq = httpTestingController.expectOne(`${CONTEXT_PATH}/job-classes`);
getJobsReq.flush(['sampleJob']);
fixture.detectChanges();

View File

@@ -3,16 +3,16 @@ import {SchedulerService} from '../../services';
import {Scheduler} from '../../model/scheduler.model';
import {SimpleTriggerCommand} from '../../model/simple-trigger.command';
import {SimpleTrigger} from '../../model/simple-trigger.model';
import * as moment from 'moment';
import {TriggerKey} from '../../model/triggerKey.model';
import JobService from '../../services/job.service';
import {MisfireInstruction, MisfireInstructionCaption} from '../../model/misfire-instruction.model';
import {AbstractControl, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, Validators} from '@angular/forms';
@Component({
selector: 'qrzmng-simple-trigger-config',
templateUrl: './simple-trigger-config.component.html',
styleUrls: ['./simple-trigger-config.component.scss']
selector: 'qrzmng-simple-trigger-config',
templateUrl: './simple-trigger-config.component.html',
styleUrls: ['./simple-trigger-config.component.scss'],
standalone: false
})
export class SimpleTriggerConfigComponent implements OnInit {
@@ -22,8 +22,8 @@ export class SimpleTriggerConfigComponent implements OnInit {
triggerName: [this.trigger?.triggerKeyDTO.name, Validators.required],
jobClass: [this.trigger?.jobDetailDTO.jobClassName, Validators.required],
triggerPeriod: this.formBuilder.group({
startDate: [this.trigger?.startTime && moment(this.trigger?.startTime)],
endDate: [this.trigger?.endTime && moment(this.trigger?.endTime)]
startDate: [this.trigger?.startTime && new Date(this.trigger.startTime)],
endDate: [this.trigger?.endTime && new Date(this.trigger.endTime)]
}, {validators: this._triggerPeriodValidator}),
triggerRecurrence: this.formBuilder.group({
repeatCount: [this.trigger?.repeatCount],
@@ -34,20 +34,23 @@ export class SimpleTriggerConfigComponent implements OnInit {
scheduler: Scheduler;
triggerLoading = true;
triggerLoading = false;
private fetchedTriggers = false;
private triggerInProgress = false;
private selectedTriggerKey: TriggerKey;
private jobs: Array<String>;
enabledTriggerForm = false;
@Output()
onNewTrigger = new EventEmitter<SimpleTrigger>();
@Output()
triggerFormOpenChange = new EventEmitter<boolean>();
@Output()
onTriggerSubmitting = new EventEmitter<TriggerKey>();
constructor(
private formBuilder: UntypedFormBuilder,
private schedulerService: SchedulerService,
@@ -56,6 +59,7 @@ export class SimpleTriggerConfigComponent implements OnInit {
}
ngOnInit() {
this.simpleTriggerReactiveForm.disable();
this.fetchJobs();
}
@@ -64,19 +68,38 @@ export class SimpleTriggerConfigComponent implements OnInit {
}
openTriggerForm() {
this.enabledTriggerForm = true;
this.simpleTriggerReactiveForm.enable();
this.triggerFormOpenChange.emit(true);
}
private closeTriggerForm() {
this.enabledTriggerForm = false;
this.simpleTriggerReactiveForm.disable();
this.triggerFormOpenChange.emit(false);
}
@Input()
set triggerKey(triggerKey: TriggerKey) {
this.selectedTriggerKey = {...triggerKey} as TriggerKey;
this.fetchSelectedTrigger();
if (!triggerKey) {
return;
} else if (!this.selectedTriggerKey || this.selectedTriggerKey.name !== triggerKey.name) {
this._resetTheTrigger();
this.selectedTriggerKey = {...triggerKey} as TriggerKey;
this.fetchSelectedTrigger();
this.simpleTriggerReactiveForm.disable();
}
}
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;
@@ -86,15 +109,20 @@ export class SimpleTriggerConfigComponent implements OnInit {
this.simpleTriggerReactiveForm.setValue(this._fromTriggerToReactiveForm(retTrigger))
this.triggerLoading = false;
this.triggerInProgress = this.trigger.mayFireAgain;
this.simpleTriggerReactiveForm.disable();
})
}
shouldShowTheTriggerCardContent = (): boolean => this.trigger !== null || this.enabledTriggerForm;
shouldShowTheTriggerCardContent = (): boolean => this.trigger !== null || this.simpleTriggerReactiveForm.enabled;
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 +131,24 @@ export class SimpleTriggerConfigComponent implements OnInit {
this.schedulerService.updateSimpleTriggerConfig : this.schedulerService.saveSimpleTriggerConfig;
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;
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,16 +157,20 @@ 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;
});
}
private _triggerPeriodValidator(control: AbstractControl): ValidationErrors | null {
const startDate = control.get('startDate');
const endDate = control.get('endDate');
if (startDate.value && endDate.value) {
return endDate.value.isBefore(startDate.value) ?
return endDate.value < startDate.value ?
<ValidationErrors>{invalidTriggerPeriod: true} : null;
}
return null;
@@ -153,22 +196,23 @@ export class SimpleTriggerConfigComponent implements OnInit {
simpleTriggerReactiveForm.jobClass = simpleTrigger.jobDetailDTO.jobClassName;
simpleTriggerReactiveForm.triggerRecurrence.repeatCount = simpleTrigger.repeatCount || null;
simpleTriggerReactiveForm.triggerRecurrence.repeatInterval = simpleTrigger.repeatInterval || null;
simpleTriggerReactiveForm.triggerPeriod.startDate = (simpleTrigger.startTime && moment(simpleTrigger.startTime)) || null;
simpleTriggerReactiveForm.triggerPeriod.endDate = (simpleTrigger.endTime && moment(simpleTrigger.endTime)) || null;
simpleTriggerReactiveForm.triggerPeriod.startDate = (simpleTrigger.startTime && new Date(simpleTrigger.startTime)) || null;
simpleTriggerReactiveForm.triggerPeriod.endDate = (simpleTrigger.endTime && new Date(simpleTrigger.endTime)) || null;
simpleTriggerReactiveForm.misfireInstruction = (simpleTrigger.misfireInstruction
&& MisfireInstruction[simpleTrigger.misfireInstruction]) || null;
return simpleTriggerReactiveForm;
};
private _fromReactiveFormToCommand = (): SimpleTriggerCommand => {
const reactiveFormValue = this.simpleTriggerReactiveForm.value;
const reactiveFormValue = this.simpleTriggerReactiveForm.getRawValue();
const simpleTriggerCommand = new SimpleTriggerCommand();
simpleTriggerCommand.triggerName = reactiveFormValue.triggerName;
simpleTriggerCommand.triggerGroup = this.selectedTriggerKey?.group || 'DEFAULT';
simpleTriggerCommand.jobClass = reactiveFormValue.jobClass;
simpleTriggerCommand.repeatCount = reactiveFormValue.triggerRecurrence.repeatCount;
simpleTriggerCommand.repeatInterval = reactiveFormValue.triggerRecurrence.repeatInterval;
simpleTriggerCommand.startDate = reactiveFormValue.triggerPeriod.startDate?.toDate();
simpleTriggerCommand.endDate = reactiveFormValue.triggerPeriod.endDate?.toDate();
simpleTriggerCommand.startDate = reactiveFormValue.triggerPeriod.startDate;
simpleTriggerCommand.endDate = reactiveFormValue.triggerPeriod.endDate;
simpleTriggerCommand.misfireInstruction = reactiveFormValue.misfireInstruction;
return simpleTriggerCommand;
}

View File

@@ -1,17 +1,42 @@
<mat-card fxFlex="1 1 auto" style="padding-left: 0; padding-right: 0">
<mat-card-header fxLayout="row" fxLayoutAlign="space-between none" style="padding-right: 1em;" >
<mat-card class="trigger-list-card" style="padding-left: 0; padding-right: 0">
<mat-card-header
class="flex flex-row justify-space-between"
style="padding-right: 1em">
<mat-card-subtitle><b>TRIGGERS</b></mat-card-subtitle>
<button *ngIf="!triggerFormIsOpen" mat-raised-button style="top: -0.5em" color="primary" (click)="onNewTriggerBtnClicked()">
@if (!triggerFormIsOpen) {
<button
mat-raised-button
style="top: -0.5em"
color="primary"
(click)="onNewTriggerBtnClicked()">
new
</button>
}
</mat-card-header>
<mat-divider></mat-divider>
<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}">
<mat-card-content class="trigger-list-content">
<mat-nav-list
style="
overflow-y: auto;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: auto;
height: calc(100% - 3em);
">
@for (triggerKey of getTriggerKeyList(); track triggerKey) {
<mat-list-item
class="triggerItemList"
[ngClass]="{
selectedTrigger:
selectedTrigger && selectedTrigger.name == triggerKey.name
}"
(click)="selectTrigger(triggerKey)">
<a matLine>{{ triggerKey.name }}</a>
</mat-list-item>
}
</mat-nav-list>
</mat-card-content>
</mat-card>

View File

@@ -1,3 +1,23 @@
:host {
display: flex;
height: 100%;
min-height: 0;
width: 100%;
}
.trigger-list-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.trigger-list-content {
flex: 1;
min-height: 0;
position: relative;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {

View File

@@ -5,14 +5,17 @@ import {SimpleTrigger} from '../../model/simple-trigger.model';
import {MatDialog, MatDialogRef} from '@angular/material/dialog';
@Component({
template: `
<h3 mat-dialog-title>Coming Soon</h3>
<div mat-dialog-content>
<p>This feature is in roadmap and it will come with the next releases</p>
</div>
<div mat-dialog-actions>
<button mat-button (click)="closeDialog()" style="padding: 0.5em;width: 5em;">Ok</button>
template: `
<div style="padding:16px">
<h3 mat-dialog-title>Coming Soon</h3>
<div mat-dialog-content>
<p>This feature is in roadmap and it will come with the next releases</p>
</div>
<div mat-dialog-actions>
<button mat-button (click)="closeDialog()" style="padding: 0.5em;width: 5em;">Ok</button>
</div>
</div>`,
standalone: false
})
// tslint:disable-next-line:component-class-suffix
export class UnsupportedMultipleJobsDialog {
@@ -24,9 +27,10 @@ export class UnsupportedMultipleJobsDialog {
}
@Component({
selector: 'qrzmng-trigger-list',
templateUrl: './trigger-list.component.html',
styleUrls: ['./trigger-list.component.scss']
selector: 'qrzmng-trigger-list',
templateUrl: './trigger-list.component.html',
styleUrls: ['./trigger-list.component.scss'],
standalone: false
})
export class TriggerListComponent implements OnInit {
@@ -81,16 +85,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

@@ -1,10 +1,10 @@
import {Injectable} from '@angular/core';
import {Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot} from '@angular/router';
import { Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import {UserService} from '../services';
import {Observable} from 'rxjs';
@Injectable()
export class AdminGuard implements CanActivate {
export class AdminGuard {
constructor(private router: Router, private userService: UserService) {
}

View File

@@ -1,10 +1,10 @@
import { Injectable } from '@angular/core';
import { Router, CanActivate } from '@angular/router';
import { Router } from '@angular/router';
import { UserService } from '../services';
import { Observable } from 'rxjs';
@Injectable()
export class GuestGuard implements CanActivate {
export class GuestGuard {
constructor(private router: Router, private userService: UserService) {}

View File

@@ -1,10 +1,10 @@
import { Injectable } from '@angular/core';
import { Router, CanActivate } from '@angular/router';
import { Router } from '@angular/router';
import { UserService } from '../services';
import { Observable } from 'rxjs';
@Injectable()
export class LoginGuard implements CanActivate {
export class LoginGuard {
constructor(private router: Router, private userService: UserService) {}

View File

@@ -0,0 +1,24 @@
import {TriggerKey} from './triggerKey.model';
export type CalendarType = 'ANNUAL' | 'CRON' | 'DAILY' | 'HOLIDAY' | 'MONTHLY' | 'WEEKLY';
export class QuartzCalendar {
name: string;
type: CalendarType = 'WEEKLY';
description: string;
cronExpression: string;
timeZone: string;
rangeStartingTime: string;
rangeEndingTime: string;
invertTimeRange: boolean;
excludedDaysOfWeek: number[];
excludedDaysOfMonth: number[];
excludedDates: Date[];
triggerKeys: TriggerKey[];
}
export class CalendarIncludedTimeTest {
time: Date;
included: boolean;
nextIncludedTime: Date;
}

View File

@@ -50,7 +50,8 @@ export const MisfireInstructionCaption = new Map<number, string>([
`In case of misfire event, the trigger is re-scheduled to the next scheduled time after 'now'
with the repeat count set to what it would be if it had not missed any firings.<br/>
Use this policy if no jobs must run after the end date time.<br/>
<strong>Warning</strong> The actual number of job executions could be less than initially set, because the misfired trigger are ignored.<br/>
<strong>Warning</strong> The actual number of job executions could be less than initially set,
because the misfired trigger are ignored.<br/>
This policy could cause the Trigger to go directly to the 'COMPLETE' state if all fire-times where missed.`
]
]);

View File

@@ -0,0 +1,7 @@
export class ScheduledJobCommand {
jobClass: string;
description: string;
durable: boolean;
requestsRecovery: boolean;
jobDataMap: {[key: string]: unknown};
}

View File

@@ -0,0 +1,12 @@
import {JobKeyModel} from './jobKey.model';
import {TriggerKey} from './triggerKey.model';
export class ScheduledJob {
jobKeyDTO: JobKeyModel;
jobClassName: string;
description: string;
durable: boolean;
requestsRecovery: boolean;
jobDataMap: {[key: string]: unknown};
triggerKeys: TriggerKey[];
}

View File

@@ -5,6 +5,14 @@ export class Scheduler {
instanceId: string;
status: string;
triggerKeys: TriggerKey[];
quartzVersion: string;
jobStoreClass: string;
jobStoreSupportsPersistence: boolean;
clustered: boolean;
threadPoolClass: string;
threadPoolSize: number;
runningSince: string;
numberOfJobsExecuted: number;
constructor(name: string, instanceId: string, status: string, triggerKeys: TriggerKey[]) {
this.name = name;

View File

@@ -1,9 +1,12 @@
export class SimpleTriggerCommand {
triggerName: string;
triggerGroup: string;
jobClass: string;
jobKey: {group: string; name: string};
startDate: Date;
endDate: Date;
repeatCount: number;
repeatInterval: number;
misfireInstruction: string;
jobDataMap: {[key: string]: unknown};
}

View File

@@ -1,10 +1,8 @@
import {Moment} from 'moment/moment';
export class SimpleTriggerForm {
triggerName: string;
jobClass: string;
startDate: Moment;
endDate: Moment;
startDate: Date;
endDate: Date;
repeatCount: number;
repeatInterval: number;
misfireInstruction: string;

View File

@@ -0,0 +1,26 @@
import {JobKeyModel} from './jobKey.model';
export type TriggerType = 'SIMPLE' | 'CRON' | 'DAILY_TIME_INTERVAL' | 'CALENDAR_INTERVAL';
export class TriggerCommand {
triggerType: TriggerType = 'SIMPLE';
jobClass: string;
jobKey: JobKeyModel;
startDate: Date;
endDate: Date;
description: string;
priority: number;
calendarName: string;
misfireInstruction: string;
jobDataMap: {[key: string]: unknown};
repeatCount: number;
repeatInterval: number;
repeatIntervalUnit: string;
cronExpression: string;
timeZone: string;
startTimeOfDay: string;
endTimeOfDay: string;
daysOfWeek: number[];
preserveHourOfDayAcrossDaylightSavings: boolean;
skipDayIfHourDoesNotExist: boolean;
}

View File

@@ -11,7 +11,22 @@ export class Trigger {
finalFireTime: Date;
misfireInstruction: number;
nextFireTime: Date;
previousFireTime: Date;
type: string;
state: string;
calendarName: string;
jobKeyDTO: JobKeyModel;
jobDetailDTO: JobDetail = new JobDetail();
mayFireAgain: boolean;
jobDataMap: {[key: string]: unknown};
cronExpression: string;
timeZone: string;
repeatInterval: number;
repeatCount: number;
repeatIntervalUnit: string;
startTimeOfDay: string;
endTimeOfDay: string;
daysOfWeek: number[];
preserveHourOfDayAcrossDaylightSavings: boolean;
skipDayIfHourDoesNotExist: boolean;
}

View File

@@ -1,74 +0,0 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
// import 'core-js/es6/symbol';
// import 'core-js/es6/object';
// import 'core-js/es6/function';
// import 'core-js/es6/parse-int';
// import 'core-js/es6/parse-float';
// import 'core-js/es6/number';
// import 'core-js/es6/math';
// import 'core-js/es6/string';
// import 'core-js/es6/date';
// import 'core-js/es6/array';
// import 'core-js/es6/regexp';
// import 'core-js/es6/map';
// import 'core-js/es6/set';
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/** IE10 and IE11 requires the following to support `@angular/animation`. */
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/** Evergreen browsers require these. **/
import 'core-js/es6/reflect';
import 'core-js/es7/reflect';
/** ALL Firefox browsers require the following to support `@angular/animation`. **/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/***************************************************************************************************
* Zone JS is required by Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
/**
* Date, currency, decimal and percent pipes.
* Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10
*/
// import 'intl'; // Run `npm install --save intl`.
/***************************************************************************************************
* MATERIAL 2
*/
import 'hammerjs/hammer';

View File

@@ -1,4 +1,4 @@
import {HttpClient, HttpHeaders, HttpResponse, HttpRequest, HttpEventType, HttpParams} from '@angular/common/http';
import { HttpClient, HttpHeaders, HttpResponse, HttpRequest, HttpEventType, HttpParams } from '@angular/common/http';
import {Router} from '@angular/router';
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';

View File

@@ -1,5 +1,5 @@
import {Injectable} from '@angular/core';
import {HttpHeaders, HttpResponse} from '@angular/common/http';
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import {ApiService} from './api.service';
import {UserService} from './user.service';
import {ConfigService} from './config.service';

View File

@@ -0,0 +1,36 @@
import {jest} from '@jest/globals';
import {CalendarService} from './calendar.service';
describe('CalendarService', () => {
let apiService: any;
let calendarService: CalendarService;
beforeEach(() => {
apiService = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
};
calendarService = new CalendarService(apiService);
});
it('uses calendar registry endpoints', () => {
const calendar: any = {name: 'weekends', type: 'WEEKLY'};
const time = new Date('2026-05-12T12:00:00.000Z');
calendarService.fetchCalendars();
calendarService.getCalendar('weekends');
calendarService.createCalendar('weekends', calendar);
calendarService.updateCalendar('weekends', calendar);
calendarService.deleteCalendar('weekends');
calendarService.testIncludedTime('weekends', time);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/calendars');
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/calendars/weekends');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/calendars/weekends', calendar);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/calendars/weekends', calendar);
expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/calendars/weekends');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/calendars/weekends/included-time-test', {time});
});
});

View File

@@ -0,0 +1,34 @@
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {ApiService} from './api.service';
import {CONTEXT_PATH, getBaseUrl} from './config.service';
import {CalendarIncludedTimeTest, QuartzCalendar} from '../model/calendar.model';
@Injectable()
export class CalendarService {
constructor(private apiService: ApiService) {}
fetchCalendars = (): Observable<QuartzCalendar[]> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars`);
}
getCalendar = (name: string): Observable<QuartzCalendar> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`);
}
createCalendar = (name: string, calendar: QuartzCalendar): Observable<QuartzCalendar> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar);
}
updateCalendar = (name: string, calendar: QuartzCalendar): Observable<QuartzCalendar> => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar);
}
deleteCalendar = (name: string): Observable<void> => {
return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`);
}
testIncludedTime = (name: string, time: Date): Observable<CalendarIncludedTimeTest> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}/included-time-test`, {time});
}
}

View File

@@ -3,10 +3,10 @@ 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 './trigger.service'
export * from './job.service'
export * from './progress.rx-websocket.service';
export * from './logs.rx-websocket.service';
export * from './trigger.service'
export * from './calendar.service'
export * from './job.service'

View File

@@ -0,0 +1,46 @@
import JobService from './job.service';
import {ScheduledJob} from '../model/scheduled-job.model';
import {jest} from '@jest/globals';
describe('JobService', () => {
let apiService: any;
let jobService: JobService;
beforeEach(() => {
apiService = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
};
jobService = new JobService(apiService);
});
it('uses job class and scheduled job endpoints', () => {
const job = new ScheduledJob();
const command = {
jobClass: 'SampleJob',
description: '',
durable: true,
requestsRecovery: false,
jobDataMap: {}
};
job.jobKeyDTO = {group: 'DEFAULT', name: 'sampleJob'};
jobService.fetchJobs();
jobService.fetchScheduledJobs();
jobService.getScheduledJob('DEFAULT', 'sampleJob');
jobService.createJob('DEFAULT', 'sampleJob', command);
jobService.updateJob('DEFAULT', 'sampleJob', command);
jobService.triggerJob(job);
jobService.deleteJob(job);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/job-classes');
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs');
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', command);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', command);
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob/trigger', {});
expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob');
});
});

View File

@@ -2,6 +2,8 @@ import {Injectable} from '@angular/core';
import {ApiService} from './api.service';
import {CONTEXT_PATH, getBaseUrl} from './config.service';
import {Observable} from 'rxjs';
import {ScheduledJob} from '../model/scheduled-job.model';
import {ScheduledJobCommand} from '../model/scheduled-job.command';
@Injectable()
export default class JobService {
@@ -12,7 +14,31 @@ export default class JobService {
}
fetchJobs = (): Observable<string[]> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/job-classes`)
}
fetchScheduledJobs = (): Observable<ScheduledJob[]> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs`)
}
getScheduledJob = (group: string, name: string): Observable<ScheduledJob> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`)
}
createJob = (group: string, name: string, command: ScheduledJobCommand): Observable<ScheduledJob> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command)
}
updateJob = (group: string, name: string, command: ScheduledJobCommand): Observable<ScheduledJob> => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/jobs/${group || 'DEFAULT'}/${name}`, command)
}
triggerJob = (job: ScheduledJob): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}/trigger`, {})
}
deleteJob = (job: ScheduledJob): Observable<void> => {
return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/jobs/${job.jobKeyDTO.group}/${job.jobKeyDTO.name}`)
}
}

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,11 @@
import {RxStomp, RxStompConfig} from '@stomp/rx-stomp';
export class RxStompService extends RxStomp {
constructor(rxStompConfig: RxStompConfig) {
super();
super.configure(rxStompConfig);
super.activate();
}
}

View File

@@ -0,0 +1,43 @@
import {SchedulerService} from './scheduler.service';
import {SimpleTriggerCommand} from '../model/simple-trigger.command';
import {jest} from '@jest/globals';
describe('SchedulerService', () => {
let apiService: any;
let schedulerService: SchedulerService;
beforeEach(() => {
apiService = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn()
};
schedulerService = new SchedulerService(apiService);
});
it('uses POST scheduler lifecycle endpoints', () => {
schedulerService.startScheduler();
schedulerService.standbyScheduler();
schedulerService.resumeScheduler();
schedulerService.shutdownScheduler();
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/start', {});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/standby', {});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/resume', {});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/scheduler/shutdown', {});
});
it('uses grouped simple trigger endpoints', () => {
const command = new SimpleTriggerCommand();
command.triggerGroup = 'DEFAULT';
command.triggerName = 'sampleTrigger';
schedulerService.getSimpleTriggerConfig(command.triggerName, command.triggerGroup);
schedulerService.saveSimpleTriggerConfig(command);
schedulerService.updateSimpleTriggerConfig(command);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger', command);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/simple-triggers/DEFAULT/sampleTrigger', command);
});
});

View File

@@ -14,20 +14,20 @@ export class SchedulerService {
private apiService: ApiService
) { }
startScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/run`);
}
stopScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/stop`);
}
pauseScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/pause`);
}
resumeScheduler = (): Observable<void> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler/resume`);
startScheduler = (): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/start`, {});
}
shutdownScheduler = (): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/shutdown`, {});
}
standbyScheduler = (): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/standby`, {});
}
resumeScheduler = (): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/scheduler/resume`, {});
}
getStatus = () => {
@@ -38,17 +38,17 @@ export class SchedulerService {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/scheduler`);
}
getSimpleTriggerConfig = (triggerName: string): Observable<Trigger> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${triggerName}`);
}
saveSimpleTriggerConfig = (config: SimpleTriggerCommand) => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerName}`, config)
}
updateSimpleTriggerConfig = (config: SimpleTriggerCommand) => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerName}`, config)
}
getSimpleTriggerConfig = (triggerName: string, triggerGroup = 'DEFAULT'): Observable<Trigger> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${triggerGroup}/${triggerName}`);
}
saveSimpleTriggerConfig = (config: SimpleTriggerCommand) => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerGroup}/${config.triggerName}`, config)
}
updateSimpleTriggerConfig = (config: SimpleTriggerCommand) => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/simple-triggers/${config.triggerGroup}/${config.triggerName}`, config)
}
}

View File

@@ -0,0 +1,42 @@
import {TriggerService} from './trigger.service';
import {TriggerKey} from '../model/triggerKey.model';
import {jest} from '@jest/globals';
describe('TriggerService', () => {
let apiService: any;
let triggerService: TriggerService;
beforeEach(() => {
apiService = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
};
triggerService = new TriggerService(apiService);
});
it('uses grouped trigger lifecycle endpoints', () => {
const triggerKey = new TriggerKey('sampleTrigger', 'DEFAULT');
triggerService.getTrigger(triggerKey);
triggerService.pauseTrigger(triggerKey);
triggerService.resumeTrigger(triggerKey);
triggerService.unscheduleTrigger(triggerKey);
expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger');
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger/pause', {});
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger/resume', {});
expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger');
});
it('uses generic trigger create and update endpoints', () => {
const command: any = {triggerType: 'CRON', cronExpression: '0 0/5 * * * ?'};
triggerService.saveTrigger('OPS', 'cronTrigger', command);
triggerService.updateTrigger('OPS', 'cronTrigger', command);
expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/OPS/cronTrigger', command);
expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/triggers/OPS/cronTrigger', command);
});
});

View File

@@ -4,6 +4,7 @@ import {Observable} from 'rxjs';
import {Trigger} from '../model/trigger.model';
import {TriggerKey} from '../model/triggerKey.model';
import {CONTEXT_PATH, getBaseUrl} from './config.service';
import {TriggerCommand} from '../model/trigger-command.model';
@Injectable()
export class TriggerService {
@@ -16,5 +17,28 @@ export class TriggerService {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/triggers`);
}
getTrigger = (triggerKey: TriggerKey): Observable<Trigger> => {
return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}`);
}
saveTrigger = (group: string, name: string, config: TriggerCommand): Observable<Trigger> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config);
}
updateTrigger = (group: string, name: string, config: TriggerCommand): Observable<Trigger> => {
return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config);
}
pauseTrigger = (triggerKey: TriggerKey): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/pause`, {});
}
resumeTrigger = (triggerKey: TriggerKey): Observable<void> => {
return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/resume`, {});
}
unscheduleTrigger = (triggerKey: TriggerKey): Observable<void> => {
return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}`);
}
}

View File

@@ -2,9 +2,10 @@ import {Injectable} from '@angular/core';
import {ApiService} from './api.service';
import {ConfigService} from './config.service';
import {map} from 'rxjs/operators'
import {HttpErrorResponse} from '@angular/common/http';
import {Router} from '@angular/router';
import {map} from 'rxjs/operators'
import { HttpErrorResponse } from '@angular/common/http';
import {Router} from '@angular/router';
import {firstValueFrom} from 'rxjs';
@Injectable()
export class UserService {
@@ -22,7 +23,7 @@ export class UserService {
refreshToken() {
this.apiService.get(this.config.refresh_token_url).subscribe(res => {
if (res.accessToken !== null) {
return this.getUserInfo().toPromise()
return firstValueFrom(this.getUserInfo())
.then(user => {
this.currentUser = user;
});
@@ -35,7 +36,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

@@ -1,4 +1,4 @@
<div fxLayout="column" fxLayoutAlign="center" style="text-align: center">
<div class="flex flex-column justify-center" style="text-align: center">
<div>
<div>
<p style="font-size: 4em; margin-bottom: 0">Unexpected Error</p>

View File

@@ -1,9 +1,10 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'qrzmng-generic-error',
templateUrl: './genericError.component.html',
styleUrls: ['./genericError.component.css']
selector: 'qrzmng-generic-error',
templateUrl: './genericError.component.html',
styleUrls: ['./genericError.component.css'],
standalone: false
})
export class GenericErrorComponent implements OnInit {

View File

@@ -1,4 +1,4 @@
<div fxLayout="column" fxLayoutAlign="center" style="text-align: center">
<div class="flex flex-column justify-center" style="text-align: center">
<div>
<div>
<p style="font-size: 4em; margin-bottom: 0">Forbidden - Access Senied</p>

View File

@@ -1,9 +1,10 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-forbidden',
templateUrl: './forbidden.component.html',
styleUrls: ['./forbidden.component.css']
selector: 'app-forbidden',
templateUrl: './forbidden.component.html',
styleUrls: ['./forbidden.component.css'],
standalone: false
})
export class ForbiddenComponent implements OnInit {

View File

@@ -1,32 +1,76 @@
<div class="content" fxLayout="row" fxLayoutAlign="center" style="padding-bottom:160px;">
<mat-card elevation="5" fxFlex>
<mat-card-subtitle>
<h2>Quartz Manager</h2>
</mat-card-subtitle>
<mat-card-title>
<h2>{{title}}</h2>
</mat-card-title>
<mat-card-content>
<p [class]="notification.msgType" *ngIf="notification">{{notification.msgBody}}</p>
<form *ngIf="!submitted" [formGroup]="form" (ngSubmit)="onSubmit()" #loginForm="ngForm">
<mat-form-field>
<input matInput formControlName="username" required placeholder="user">
</mat-form-field>
<mat-form-field>
<input matInput formControlName="password" required type="password" placeholder="password">
</mat-form-field>
<button type="submit" [disabled]="!loginForm.form.valid" mat-raised-button color="primary">Login</button>
</form>
<mat-spinner *ngIf="submitted" mode="indeterminate"></mat-spinner>
</mat-card-content>
</mat-card>
</div>
<section class="login-shell">
<div class="login-hero" aria-hidden="true">
<div class="brand">
<span class="brand-mark">QM</span>
<div>
<h1>Quartz Manager</h1>
<p>Scheduler operations console</p>
</div>
</div>
<div class="hero-card">
<span class="card-title">Operational View</span>
<div class="status-row">
<span class="pulse"></span>
<span>Jobs, triggers, logs and live execution state</span>
</div>
<div class="metric-grid">
<div>
<strong>01</strong>
<span>Secure entry</span>
</div>
<div>
<strong>24/7</strong>
<span>Runtime visibility</span>
</div>
</div>
</div>
</div>
<mat-card class="login-card">
<mat-card-content>
<div class="form-header">
<span class="eyebrow">Welcome back</span>
<h2>{{ title }}</h2>
<p>Sign in to manage scheduler activity and inspect runtime signals.</p>
</div>
@if (notification) {
<p class="notification {{ notification.msgType }}">{{ notification.msgBody }}</p>
} @if (!submitted) {
<form [formGroup]="form" (ngSubmit)="onSubmit()" #loginForm="ngForm">
<mat-form-field appearance="outline">
<mat-label>Username</mat-label>
<input
matInput
formControlName="username"
required
autocomplete="username" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Password</mat-label>
<input
matInput
formControlName="password"
required
type="password"
autocomplete="current-password" />
</mat-form-field>
<button
class="login-button"
type="submit"
[disabled]="!loginForm.form.valid"
mat-raised-button
color="primary">
Login
</button>
</form>
} @if (submitted) {
<div class="loading-state">
<mat-spinner mode="indeterminate"></mat-spinner>
<span>Checking credentials...</span>
</div>
}
</mat-card-content>
</mat-card>
</section>

View File

@@ -1,58 +1,268 @@
.content {
:host {
--bg: oklch(98% 0.005 250);
--surface: oklch(100% 0 0);
--fg: oklch(22% 0.02 240);
--muted: oklch(50% 0.018 240);
--border: oklch(90% 0.008 240);
--accent: oklch(56% 0.19 302);
--success: oklch(58% 0.16 145);
--danger: oklch(58% 0.19 28);
--radius: 8px;
display: block;
flex: 1;
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
.login-shell {
width: 100%;
min-height: min(680px, calc(100vh - 170px));
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 430px);
gap: 20px;
align-items: stretch;
padding: 18px;
border: 1px solid var(--border);
border-radius: 14px;
background:
radial-gradient(circle at top left, oklch(56% 0.19 302 / 0.16), transparent 34%),
var(--bg);
animation: fadein 1s;
-o-animation: fadein 1s;
-moz-animation: fadein 1s;
-webkit-animation: fadein 1s;
}
.login-hero {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 430px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 12px;
background:
linear-gradient(145deg, oklch(99% 0.003 250 / 0.92), oklch(95% 0.018 285 / 0.92)),
var(--surface);
}
.login-hero::after {
content: "";
position: absolute;
inset: auto -80px -95px auto;
width: 260px;
height: 260px;
border-radius: 999px;
background: oklch(56% 0.19 302 / 0.13);
}
.brand {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--accent);
color: white;
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 13px;
font-weight: 800;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 21px;
line-height: 1.1;
}
.brand p,
.form-header p,
.status-row,
.metric-grid span,
.loading-state span {
color: var(--muted);
}
.hero-card {
position: relative;
z-index: 1;
display: grid;
gap: 18px;
max-width: 440px;
padding: 18px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: oklch(100% 0 0 / 0.78);
box-shadow: 0 22px 60px oklch(22% 0.02 240 / 0.10);
backdrop-filter: blur(12px);
}
.card-title,
.eyebrow,
.metric-grid strong {
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
}
.card-title,
.eyebrow {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
}
.status-row {
display: flex;
align-items: center;
gap: 9px;
line-height: 1.45;
}
.pulse {
width: 10px;
height: 10px;
flex: 0 0 auto;
border-radius: 999px;
background: var(--success);
box-shadow: 0 0 0 6px oklch(58% 0.16 145 / 0.12);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.metric-grid div {
display: grid;
gap: 5px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.metric-grid strong {
font-size: 24px;
line-height: 1;
}
.login-card {
align-self: center;
width: 100%;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
box-shadow: 0 24px 70px oklch(22% 0.02 240 / 0.14);
}
.login-card mat-card-content {
display: grid;
gap: 22px;
padding: 30px;
}
.form-header {
display: grid;
gap: 8px;
}
.form-header h2 {
font-size: 28px;
line-height: 1.05;
}
.form-header p {
line-height: 1.45;
}
form {
display: grid;
gap: 12px;
}
mat-form-field,
.login-button {
width: 100%;
}
mat-card {
max-width: 350px;
text-align: center;
animation: fadein 1s;
-o-animation: fadein 1s; /* Opera */
-moz-animation: fadein 1s; /* Firefox */
-webkit-animation: fadein 1s; /* Safari and Chrome */
}
mat-form-field {
display: block;
.login-button {
min-height: 44px;
border-radius: 7px;
font-weight: 700;
}
mat-spinner {
width: 25px;
height: 25px;
margin: 20px auto 0 auto;
}
button {
display: block;
width: 100%;
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 128px;
font-size: 13px;
}
.notification {
margin: 0;
padding: 12px 14px;
border-radius: var(--radius);
line-height: 1.4;
}
.error {
color: #D50000;
border: 1px solid oklch(58% 0.19 28 / 0.30);
background: oklch(98% 0.02 28);
color: var(--danger);
}
.success {
color: #8BC34A;
border: 1px solid oklch(58% 0.16 145 / 0.30);
background: oklch(98% 0.02 145);
color: var(--success);
}
@media screen and (max-width: 599px) {
.content {
/* https://github.com/angular/flex-layout/issues/295 */
display: block !important;
@media screen and (max-width: 760px) {
.login-shell {
grid-template-columns: 1fr;
min-height: auto;
padding: 12px;
}
mat-card {
/* https://github.com/angular/flex-layout/issues/295 */
display: block !important;
max-width: 999px;
.login-hero {
min-height: auto;
gap: 24px;
padding: 18px;
}
}
.login-card mat-card-content {
padding: 24px 20px;
}
a {
text-decoration: none;
cursor: auto;
color: #FFFFFF;
.form-header h2 {
font-size: 25px;
}
}

View File

@@ -7,10 +7,11 @@ import {delay, takeUntil} from 'rxjs/operators'
import {AuthService, UserService} from '../../services';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
standalone: false
})
export class LoginComponent implements OnInit, OnDestroy {
title = 'Login';

View File

@@ -1,40 +1,382 @@
<div id="managerViewContainer" fxLayout="column" fxLayoutAlign="left stretch" fxLayoutGap="10px" fxFill>
<div id="schedulerBarContainer" fxLayout="column" fxLayoutAlign="left stretch">
<qrzmng-scheduler-control></qrzmng-scheduler-control>
</div>
<div fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="center stretch" fxFlex="1 1 auto">
<div fxFlex="0 0 250px">
<div fxLayout="row" fxLayoutAlign="stretch" fxFill>
<qrzmng-trigger-list
(onNewTriggerClicked)="onNewTriggerRequested()"
[openedNewTriggerForm]="newTriggerFormOpened"
(onSelectedTrigger)="setSelectedTrigger($event)"
fxFill></qrzmng-trigger-list>
</div>
</div>
<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>
</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>
</div>
</div>
</div>
</div>
<div class="qm-app" [class.object-mode]="activePage !== 'dashboard'" (click)="handleConsoleClick($event)">
<aside class="rail" aria-label="Primary navigation">
<div class="brand">
<div class="brand-mark">QM</div>
<div>
<div class="brand-title">Quartz Manager</div>
<div class="brand-subtitle">Operations Console</div>
</div>
</div>
<nav class="nav">
<button type="button" [class.active]="activePage === 'dashboard'" [attr.aria-current]="activePage === 'dashboard' ? 'page' : null" (click)="selectPage('dashboard')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 13h8V3H3v10Zm10 8h8V3h-8v18ZM3 21h8v-6H3v6Z"/></svg><span>Dashboard</span>
</button>
<button type="button" [class.active]="activePage === 'jobs'" [attr.aria-current]="activePage === 'jobs' ? 'page' : null" (click)="selectPage('jobs')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M7 8h10M7 12h10M7 16h6"/><rect x="4" y="4" width="16" height="16" rx="2"/></svg><span>Jobs</span>
</button>
<button type="button" [class.active]="activePage === 'triggers'" [attr.aria-current]="activePage === 'triggers' ? 'page' : null" (click)="selectPage('triggers')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="9"/></svg><span>Triggers</span>
</button>
<button type="button" [class.active]="activePage === 'calendars'" [attr.aria-current]="activePage === 'calendars' ? 'page' : null" (click)="selectPage('calendars')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M7 3v4M17 3v4M4 9h16M5 5h14v15H5z"/></svg><span>Calendars</span>
</button>
<button type="button" [class.active]="activePage === 'executions'" [attr.aria-current]="activePage === 'executions' ? 'page' : null" (click)="selectPage('executions')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 17h16M7 17V7m5 10V4m5 13v-6"/></svg><span>Executions</span>
</button>
<button type="button" [class.active]="activePage === 'events'" [attr.aria-current]="activePage === 'events' ? 'page' : null" (click)="selectPage('events')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg><span>Event Stream</span>
</button>
<button type="button" [class.active]="activePage === 'scheduler'" [attr.aria-current]="activePage === 'scheduler' ? 'page' : null" (click)="selectPage('scheduler')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="m19.4 15 .6 2-1.7 3-2.1-.5a8.3 8.3 0 0 1-2 1.1L13.5 23h-3l-.7-2.4a8.3 8.3 0 0 1-2-1.1l-2.1.5-1.7-3 .6-2a8.9 8.9 0 0 1 0-2.1l-.6-2 1.7-3 2.1.5a8.3 8.3 0 0 1 2-1.1l.7-2.4h3l.7 2.4a8.3 8.3 0 0 1 2 1.1l2.1-.5 1.7 3-.6 2a8.9 8.9 0 0 1 0 2.1Z"/></svg><span>Scheduler</span>
</button>
</nav>
<div class="rail-card">
<h3>Live channel</h3>
<div class="connection"><span>WebSocket</span><span class="chip success">OPEN</span></div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="scheduler-meta">
<div class="scheduler-title">
<h1>Quartz Operations Console</h1>
<div class="caption">{{ scheduler?.name || 'quartz-manager-scheduler' }} / compact context</div>
</div>
<span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || 'LOADING' }}</span>
<div class="kv"><span>Instance ID</span><span>{{ scheduler?.instanceId || '-' }}</span></div>
<div class="kv"><span>Cluster</span><span>{{ scheduler?.clustered ? 'YES' : 'NO' }}</span></div>
<div class="kv"><span>WebSocket</span><span>OPEN</span></div>
</div>
<div class="actions compact-actions" aria-label="Compact scheduler status actions">
<button type="button" class="btn compact" (click)="toggleStandby()">{{ scheduler?.status === 'PAUSED' ? 'Resume' : 'Standby' }}</button>
<button type="button" class="btn compact" (click)="jumpToScheduler()">Scheduler</button>
</div>
</header>
<section class="content">
<div class="page" [class.active]="activePage === 'dashboard'">
<div class="dashboard-grid">
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Scheduler Command Center</h2><span class="caption">Supported lifecycle commands call the current backend</span></div>
<div class="card-body scheduler-command-grid">
<div class="command-panel">
<div class="command-row" aria-label="Dashboard scheduler actions">
<button type="button" class="btn primary" (click)="startScheduler()">Start</button>
<button type="button" class="btn" (click)="standbyScheduler()">Standby</button>
<button type="button" class="btn" (click)="resumeScheduler()">Resume</button>
<button type="button" class="btn" data-roadmap="Pause all trigger groups is not available in the current backend">Pause All</button>
<button type="button" class="btn danger" data-roadmap="Clear scheduler is not available in the current backend">Clear</button>
<button type="button" class="btn danger" (click)="shutdownScheduler()">Shutdown</button>
</div>
<div class="help">Global lifecycle operations are centralized here. Group-level and destructive data operations stay visible as roadmap actions until backend endpoints exist.</div>
</div>
<div class="metadata-grid">
<div class="field"><label>Scheduler name</label><strong>{{ scheduler?.name || '-' }}</strong></div>
<div class="field"><label>Instance ID</label><strong>{{ scheduler?.instanceId || '-' }}</strong></div>
<div class="field"><label>Status</label><strong>{{ scheduler?.status || '-' }}</strong></div>
<div class="field"><label>Triggers</label><strong>{{ triggerKeys.length }}</strong></div>
<div class="field"><label>Eligible jobs</label><strong>{{ jobs.length }}</strong></div>
<div class="field"><label>Quartz metadata</label><strong>{{ scheduler?.quartzVersion || '-' }}</strong></div>
</div>
</div>
</section>
<article class="card span-3"><div class="card-body metric"><span class="chip running">TRIGGERS</span><div class="metric-value">{{ triggerKeys.length }}</div><div class="metric-label">Trigger keys returned by backend</div><div class="metric-line"><span style="--w: 64%"></span></div></div></article>
<article class="card span-3"><div class="card-body metric"><span class="chip blocked">JOBS</span><div class="metric-value">{{ jobs.length }}</div><div class="metric-label">Eligible job classes</div><div class="metric-line"><span style="--w: 48%"></span></div></div></article>
<article class="card span-3"><div class="card-body metric"><span class="chip warn">EVENTS</span><div class="metric-value">{{ getExecutionLoadValue() }}</div><div class="metric-label">Logs received for selected trigger</div><div class="metric-line"><span style="--w: 32%"></span></div></div></article>
<article class="card span-3"><div class="card-body metric"><span class="chip accent">STATUS</span><div class="metric-value compact-metric">{{ scheduler?.status || '-' }}</div><div class="metric-label">Scheduler lifecycle state</div><div class="metric-line"><span style="--w: 67%"></span></div></div></article>
<section class="card span-7">
<div class="card-header"><h2 class="card-title">Next Scheduled Fires</h2><div class="toolbar"><span class="chip normal">LIVE</span><button type="button" class="btn" (click)="selectPage('triggers')">Open Triggers</button></div></div>
<div class="split">
<div class="table-wrap">
<table>
<thead><tr><th style="width:22%">Trigger</th><th style="width:15%">Group</th><th style="width:18%">Type</th><th style="width:13%">State</th><th style="width:16%">Job</th><th style="width:16%">Next fire</th></tr></thead>
<tbody>
@for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) {
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)">
<td class="mono">{{ triggerKey.name }}</td>
<td class="mono">{{ getTriggerGroup(triggerKey) }}</td>
<td>{{ getTriggerType(triggerKey) }}</td>
<td><span class="chip" [ngClass]="getTriggerStateClass(triggerKey)">{{ getTriggerState(triggerKey) }}</span></td>
<td class="mono">{{ getTriggerJobName(triggerKey) }}</td>
<td class="mono">{{ getTriggerNextFireLabel(triggerKey) }}</td>
</tr>
} @empty {
<tr><td colspan="6">No triggers returned by the backend. Use the wizard to create a SimpleTrigger.</td></tr>
}
</tbody>
</table>
</div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'dashboard'" aria-label="Trigger detail drawer">
@if (selectedTriggerKey) {
<div class="drawer-title"><div><span class="chip" [ngClass]="getSelectedTriggerStateClass()">{{ getSelectedTriggerState() }}</span><h2>{{ selectedTriggerKey.name }}</h2><div class="caption">{{ getSelectedTriggerGroup() }} / linked to {{ getSelectedJobName() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab" data-roadmap="Per-trigger execution history is on the roadmap">Executions</button><button type="button" class="tab" (click)="selectPage('events')">Logs</button></div>
<div class="field-grid">
<div class="field"><label>Previous fire</label><strong>{{ selectedTrigger?.timesTriggered ? 'tracked by progress events' : 'not exposed' }}</strong></div>
<div class="field"><label>Next fire</label><strong>{{ formatDateTime(selectedTrigger?.nextFireTime) || '-' }}</strong></div>
<div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div>
<div class="field"><label>Calendar</label><strong>{{ selectedTrigger?.calendarName || 'none' }}</strong></div>
<div class="field"><label>Misfire</label><strong>{{ selectedTrigger?.misfireInstruction || '-' }}</strong></div>
<div class="field"><label>Repeat</label><strong>{{ getSelectedTriggerRepeatSummary() }}</strong></div>
</div>
<div class="progress-card"><div class="caption">Current run progress</div><div class="progress-line"><span [style.width.%]="getProgressPercentage()"></span></div><div class="mono">{{ getProgressLabel() }}</div></div>
<div class="actions"><button type="button" class="btn" (click)="pauseSelectedTrigger()">Pause</button><button type="button" class="btn" (click)="resumeSelectedTrigger()">Resume</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn danger" (click)="unscheduleSelectedTrigger()">Unschedule</button></div>
} @else {
<div class="drawer-title"><div><span class="chip warn">EMPTY</span><h2>No trigger selected</h2><div class="caption">Create a SimpleTrigger or refresh trigger keys.</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
}
</aside>
</div>
</section>
<section class="card span-5">
<div class="card-header"><h2 class="card-title">Execution Load</h2><span class="caption">Analytics roadmap preview</span></div>
<div class="card-body">
<div class="mini-chart" data-roadmap="Execution analytics are not exposed by the current backend" aria-label="Execution load chart">
<span class="bar" style="--h:34%"></span><span class="bar" style="--h:42%"></span><span class="bar" style="--h:28%"></span><span class="bar" style="--h:62%"></span><span class="bar warn" style="--h:52%"></span><span class="bar" style="--h:38%"></span><span class="bar" style="--h:55%"></span><span class="bar" style="--h:72%"></span><span class="bar error" style="--h:44%"></span><span class="bar" style="--h:67%"></span><span class="bar" style="--h:46%"></span><span class="bar" style="--h:58%"></span><span class="bar warn" style="--h:81%"></span><span class="bar" style="--h:64%"></span><span class="bar" style="--h:35%"></span><span class="bar" style="--h:50%"></span><span class="bar" style="--h:70%"></span><span class="bar" style="--h:40%"></span>
</div>
<div class="field-grid top-space">
<div class="field"><label>Logs received</label><strong>{{ logs.length }}</strong></div>
<div class="field"><label>Current progress</label><strong>{{ getProgressPercentage() }}%</strong></div>
<button type="button" class="field field-button" data-roadmap="Misfire analytics are on the roadmap"><label>Misfires</label><strong>Roadmap</strong></button>
<button type="button" class="field field-button" data-roadmap="Recovering jobs endpoint is on the roadmap"><label>Recovering jobs</label><strong>Roadmap</strong></button>
</div>
</div>
</section>
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Event Stream</h2><div class="toolbar"><input class="search" value="Filter: selected trigger logs" data-roadmap="Event stream filtering is on the roadmap"><span class="chip normal">STREAMING</span><button type="button" class="btn" data-roadmap="Pausing the merged event stream is on the roadmap">Pause</button><button type="button" class="btn" data-roadmap="Event export is on the roadmap">Export</button></div></div>
<div class="stream">
<div class="stream-row"><span>Time</span><span>Severity</span><span>Type</span><span>Source</span><span>Message</span></div>
@for (log of logs; track log.time) {
<div class="stream-row"><span class="mono">{{ log.time | date:'HH:mm:ss' }}</span><span class="chip" [ngClass]="log.severity === 'ERROR' ? 'danger' : log.severity === 'WARN' ? 'warn' : 'success'">{{ log.severity }}</span><span>{{ log.type }}</span><span class="mono">{{ log.source }}</span><span>{{ log.message }}</span></div>
} @empty {
<div class="stream-row muted-row"><span class="mono">--</span><span class="chip warn">WAIT</span><span>JOB_LOG</span><span class="mono">{{ selectedTriggerKey?.name || '-' }}</span><span>Waiting for log messages from the selected trigger.</span></div>
}
</div>
</section>
</div>
</div>
<div class="page" [class.active]="activePage === 'jobs'">
<div class="page-kicker">
<div><h2>Jobs</h2><p>The backend exposes scheduled Quartz jobs plus eligible job classes for SimpleTrigger creation. Durability, recovery, data map, and related trigger keys are read-only in this release.</p></div>
<div class="toolbar"><input class="search" name="jobSearch" placeholder="Filter jobs, groups, classes" [(ngModel)]="jobSearch"><select class="select compact-select" name="jobGroupFilter" [(ngModel)]="jobGroupFilter"><option value="ALL">All groups</option>@for (group of getJobGroups(); track group) { <option [value]="group">{{ group }}</option> }</select><button type="button" class="btn primary" (click)="openCreateJobWizard()">New Job</button></div>
</div>
<section class="card">
<div class="card-header"><h2 class="card-title">Scheduled Jobs</h2><div class="toolbar"><span class="chip normal">{{ getScheduledJobRows().length }} / {{ scheduledJobs.length }} JOBS</span><button type="button" class="btn" data-roadmap="Pause job group is on the roadmap">Pause Group</button><button type="button" class="btn" data-roadmap="Job export is on the roadmap">Export</button></div></div>
<div class="split">
<div class="table-wrap">
<table>
<thead><tr><th style="width:22%">Job key</th><th style="width:48%">Class</th><th style="width:10%">Durable</th><th style="width:10%">Recovery</th><th style="width:10%">Triggers</th></tr></thead>
<tbody>
@for (job of getScheduledJobRows(); track job.jobKeyDTO.group + '.' + job.jobKeyDTO.name) {
<tr class="selectable" [class.selected]="selectedScheduledJob?.jobKeyDTO?.name === job.jobKeyDTO.name && selectedScheduledJob?.jobKeyDTO?.group === job.jobKeyDTO.group" (click)="selectScheduledJob(job)"><td class="mono">{{ job.jobKeyDTO.group }}.{{ job.jobKeyDTO.name }}</td><td class="mono">{{ job.jobClassName }}</td><td><span class="chip" [ngClass]="job.durable ? 'normal' : 'warn'">{{ job.durable ? 'YES' : 'NO' }}</span></td><td><span class="chip" [ngClass]="job.requestsRecovery ? 'normal' : 'warn'">{{ job.requestsRecovery ? 'YES' : 'NO' }}</span></td><td class="mono">{{ job.triggerKeys?.length || 0 }}</td></tr>
} @empty {
<tr><td colspan="5">No scheduled jobs returned by the backend. Create a SimpleTrigger from an eligible job class.</td></tr>
}
</tbody>
</table>
</div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'jobs'" aria-label="Job detail drawer">
<div class="drawer-title"><div><span class="chip normal">SCHEDULED</span><h2>{{ getSelectedJobShortName() }}</h2><div class="caption">{{ getSelectedJobKeyLabel() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab">Triggers</button><button type="button" class="tab">Data Map</button><button type="button" class="tab" data-roadmap="Job execution history is on the roadmap">Executions</button></div>
<div class="field-grid">
<div class="field"><label>Class</label><strong>{{ getSelectedJobShortName() }}</strong></div>
<div class="field"><label>Group</label><strong>{{ selectedScheduledJob?.jobKeyDTO?.group || '-' }}</strong></div>
<div class="field"><label>Durable</label><strong>{{ selectedScheduledJob?.durable ? 'YES' : 'NO' }}</strong></div>
<div class="field"><label>Requests recovery</label><strong>{{ selectedScheduledJob?.requestsRecovery ? 'YES' : 'NO' }}</strong></div>
</div>
<pre class="code-block">JobDataMap
{{ getSelectedJobDataMapPreview() }}</pre>
<pre class="code-block">Triggers
@for (triggerKey of selectedScheduledJob?.triggerKeys || []; track triggerKey.group + '.' + triggerKey.name) { {{ triggerKey.group }}.{{ triggerKey.name }}
} @empty { none }</pre>
<div class="actions"><button type="button" class="btn primary" (click)="triggerSelectedJobNow()">Trigger Now</button><button type="button" class="btn" (click)="openEditJobWizard()">Edit Job</button><button type="button" class="btn" data-roadmap="Pause job is on the roadmap">Pause</button><button type="button" class="btn" (click)="openCreateTriggerWizard(); triggerDraft.jobTargetType = 'stored'; triggerDraft.storedJobKey = selectedScheduledJob ? selectedScheduledJob.jobKeyDTO.group + '::' + selectedScheduledJob.jobKeyDTO.name : triggerDraft.storedJobKey">Create SimpleTrigger</button></div>
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Interrupt remains roadmap-gated. Delete uses the scheduled job endpoint.</span><div class="actions"><button type="button" class="btn danger" data-roadmap="Job interruption is on the roadmap">Interrupt</button><button type="button" class="btn danger" (click)="deleteSelectedJob()">Delete Job</button></div></div>
</aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'triggers'">
<div class="page-kicker">
<div><h2>Triggers</h2><p>The backend currently supports SimpleTrigger listing, details, creation, and rescheduling. Other trigger families and per-trigger operations are shown with roadmap messaging.</p></div>
<div class="toolbar"><input class="search" name="triggerSearch" placeholder="Filter triggers, jobs, groups" [(ngModel)]="triggerSearch"><select class="select compact-select" name="triggerGroupFilter" [(ngModel)]="triggerGroupFilter"><option value="ALL">All groups</option>@for (group of getTriggerGroups(); track group) { <option [value]="group">{{ group }}</option> }</select><button type="button" class="btn primary" (click)="openCreateTriggerWizard()">Create Trigger</button></div>
</div>
<section class="card">
<div class="card-header"><h2 class="card-title">Trigger Inventory</h2><div class="toolbar"><span class="chip normal">{{ getTriggerRows().length }} / {{ triggerKeys.length }} TOTAL</span><span class="chip warn" data-roadmap="Trigger state counts are on the roadmap">STATE COUNTS ROADMAP</span></div></div>
<div class="split">
<div class="table-wrap">
<table>
<thead><tr><th style="width:18%">Trigger</th><th style="width:12%">Group</th><th style="width:15%">Type</th><th style="width:12%">State</th><th style="width:18%">Job</th><th style="width:15%">Next fire</th><th style="width:10%">Misfire</th></tr></thead>
<tbody>
@for (triggerKey of getTriggerRows(); track getTriggerGroup(triggerKey) + '.' + triggerKey.name) {
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)"><td class="mono">{{ triggerKey.name }}</td><td class="mono">{{ getTriggerGroup(triggerKey) }}</td><td>{{ getTriggerType(triggerKey) }}</td><td><span class="chip" [ngClass]="getTriggerStateClass(triggerKey)">{{ getTriggerState(triggerKey) }}</span></td><td class="mono">{{ getTriggerJobName(triggerKey) }}</td><td class="mono">{{ getTriggerNextFireLabel(triggerKey) }}</td><td class="mono">{{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}</td></tr>
} @empty {
<tr><td colspan="7">No triggers returned by the backend.</td></tr>
}
</tbody>
</table>
</div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'triggers'" aria-label="Trigger detail drawer">
<div class="drawer-title"><div><span class="chip" [ngClass]="getSelectedTriggerStateClass()">{{ getSelectedTriggerState() }}</span><h2>{{ selectedTriggerKey?.name || 'No trigger' }}</h2><div class="caption">SimpleTrigger / {{ getSelectedTriggerGroup() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab">Schedule</button><button type="button" class="tab" data-roadmap="Quartz calendars are on the roadmap">Calendar</button><button type="button" class="tab" data-roadmap="Trigger execution history is on the roadmap">Executions</button></div>
<div class="field-grid">
<div class="field"><label>Linked job</label><strong>{{ getSelectedJobName() }}</strong></div>
<div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div>
<div class="field"><label>Final fire</label><strong>{{ formatDateTime(selectedTrigger?.finalFireTime) || 'none' }}</strong></div>
<button type="button" class="field field-button" data-roadmap="Timezone metadata is on the roadmap"><label>Timezone</label><strong>Roadmap</strong></button>
<div class="field"><label>Repeat interval</label><strong>{{ selectedTrigger?.repeatInterval ? formatDuration(selectedTrigger.repeatInterval) : '-' }}</strong></div>
<div class="field"><label>Calendar</label><strong>{{ selectedTrigger?.calendarName || 'none' }}</strong></div>
</div>
<section class="preview"><h4>Schedule summary</h4><div>{{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.</div></section>
<pre class="code-block">Trigger JobDataMap
{{ getSelectedTriggerDataMapPreview() }}</pre>
<div class="actions"><button type="button" class="btn" (click)="pauseSelectedTrigger()">Pause</button><button type="button" class="btn" (click)="resumeSelectedTrigger()">Resume</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn" data-roadmap="Trigger duplication is on the roadmap">Duplicate</button></div>
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Unschedule uses the trigger lifecycle endpoint. Reset-error remains roadmap-gated.</span><div class="actions"><button type="button" class="btn danger" (click)="unscheduleSelectedTrigger()">Unschedule</button><button type="button" class="btn danger" data-roadmap="Reset error trigger is on the roadmap">Reset Error</button></div></div>
</aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'calendars'">
<div class="page-kicker">
<div><h2>Calendars</h2><p>Manage Quartz calendar exclusions and inspect which triggers are attached to each calendar.</p></div>
<div class="toolbar"><input class="search" name="calendarSearch" placeholder="Filter calendars" [(ngModel)]="calendarSearch"><button type="button" class="btn primary" (click)="openCreateCalendarWizard()">New Calendar</button></div>
</div>
<section class="card">
<div class="card-header"><h2 class="card-title">Calendar Registry</h2><span class="chip normal">{{ getCalendarRows().length }} / {{ calendars.length }} CALENDARS</span></div>
<div class="split">
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Type</th><th>Description</th><th>Triggers</th></tr></thead><tbody>@for (calendar of getCalendarRows(); track calendar.name) { <tr class="selectable" [class.selected]="selectedCalendar?.name === calendar.name" (click)="selectCalendar(calendar)"><td class="mono">{{ calendar.name }}</td><td><span class="chip accent">{{ calendar.type }}</span></td><td>{{ calendar.description || '-' }}</td><td class="mono">{{ calendar.triggerKeys?.length || 0 }}</td></tr> } @empty { <tr><td colspan="4">No calendars registered. Create one to exclude time windows from trigger firing.</td></tr> }</tbody></table></div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'calendars'" aria-label="Calendar detail drawer"><div class="drawer-title"><div><span class="chip accent">{{ selectedCalendar?.type || 'NONE' }}</span><h2>{{ selectedCalendar?.name || 'No calendar' }}</h2><div class="caption">{{ selectedCalendar?.description || 'Select a calendar to inspect rules.' }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div><div class="field-grid"><div class="field"><label>Type</label><strong>{{ selectedCalendar?.type || '-' }}</strong></div><div class="field"><label>Attached triggers</label><strong>{{ selectedCalendar?.triggerKeys?.length || 0 }}</strong></div><div class="field"><label>Cron</label><strong>{{ selectedCalendar?.cronExpression || '-' }}</strong></div><div class="field"><label>Time zone</label><strong>{{ selectedCalendar?.timeZone || '-' }}</strong></div></div><pre class="code-block">Triggers
@for (triggerKey of selectedCalendar?.triggerKeys || []; track triggerKey.group + '.' + triggerKey.name) { {{ triggerKey.group }}.{{ triggerKey.name }}
} @empty { none }</pre><div class="control"><label>Included time test</label><input class="input mono" type="datetime-local" name="calendarIncludedTime" [(ngModel)]="calendarDraft.includedTime"></div><div class="help">{{ calendarIncludedTimeResult || 'Test whether this calendar includes a specific timestamp.' }}</div><div class="actions"><button type="button" class="btn" (click)="testSelectedCalendarTime()">Test Time</button><button type="button" class="btn" (click)="openEditCalendarWizard()">Edit</button><button type="button" class="btn danger" (click)="deleteSelectedCalendar()">Delete</button></div></aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'executions'">
<div class="page-kicker">
<div><h2>Executions</h2><p>Currently executing jobs, fire instance IDs, refire counts, execution history, and interruption by fire instance are roadmap backend features.</p></div>
<div class="toolbar"><input class="search" value="Filter running jobs" data-roadmap="Execution filtering is on the roadmap"><button type="button" class="btn" data-roadmap="Execution refresh endpoint is on the roadmap">Refresh</button></div>
</div>
<section class="card roadmap-card" data-roadmap="Currently executing jobs endpoint is on the roadmap">
<div class="card-header"><h2 class="card-title">Currently Executing Jobs</h2><div class="toolbar"><span class="chip warn">ROADMAP</span></div></div>
<div class="split">
<div class="table-wrap"><table><thead><tr><th>Fire instance</th><th>Job</th><th>Trigger</th><th>Run time</th><th>Node</th></tr></thead><tbody><tr class="selectable" (click)="openDetailDrawer()"><td class="mono">Roadmap</td><td class="mono">{{ getSelectedJobName() }}</td><td class="mono">{{ selectedTriggerKey?.name || '-' }}</td><td class="mono">Roadmap</td><td class="mono">Roadmap</td></tr></tbody></table></div>
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'executions'" aria-label="Execution detail drawer"><div class="drawer-title"><div><span class="chip warn">ROADMAP</span><h2>Execution Inspector</h2><div class="caption">Live progress remains available through the selected trigger websocket.</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div><div class="progress-card"><div class="caption">Selected trigger progress</div><div class="progress-line"><span [style.width.%]="getProgressPercentage()"></span></div><div class="mono">{{ getProgressLabel() }}</div></div><div class="warning-box"><strong>Interrupt confirmation</strong><span>Interrupt operations need backend support and explicit operator confirmation.</span></div><div class="actions"><button type="button" class="btn danger" data-roadmap="Interrupt by fire instance is on the roadmap">Interrupt Fire Instance</button><button type="button" class="btn danger" data-roadmap="Interrupt by job key is on the roadmap">Interrupt Job Key</button></div></aside>
</div>
</section>
</div>
<div class="page" [class.active]="activePage === 'events'">
<div class="page-kicker">
<div><h2>Event Stream</h2><p>The current backend exposes per-trigger log and progress websocket topics. Global event aggregation, filters, saved views, and export are roadmap features.</p></div>
<div class="toolbar"><input class="search" value="Search messages, job keys, fire ids" data-roadmap="Event searching is on the roadmap"><button type="button" class="btn" data-roadmap="Pause global stream is on the roadmap">Pause Stream</button><button type="button" class="btn" data-roadmap="Export CSV is on the roadmap">Export CSV</button></div>
</div>
<div class="two-column">
<section class="card">
<div class="card-header"><h2 class="card-title">Live Events</h2><div class="toolbar"><span class="chip normal">TRIGGER STREAM</span><span class="chip accent">{{ logs.length }} EVENTS</span></div></div>
<div class="stream tall-stream">
<div class="stream-row"><span>Time</span><span>Severity</span><span>Type</span><span>Source</span><span>Message</span></div>
@for (log of logs; track log.time) {
<div class="stream-row"><span class="mono">{{ log.time | date:'HH:mm:ss' }}</span><span class="chip" [ngClass]="log.severity === 'ERROR' ? 'danger' : log.severity === 'WARN' ? 'warn' : 'success'">{{ log.severity }}</span><span>{{ log.type }}</span><span class="mono">{{ log.source }}</span><span>{{ log.message }}</span></div>
} @empty {
<div class="stream-row muted-row"><span class="mono">--</span><span class="chip warn">WAIT</span><span>JOB_LOG</span><span class="mono">{{ selectedTriggerKey?.name || '-' }}</span><span>Select or fire a trigger to receive backend log messages.</span></div>
}
</div>
</section>
<aside class="filter-panel">
<h3>Filters</h3>
<div class="control"><label>Severity</label><select class="select" data-roadmap="Severity filtering is on the roadmap"><option>INFO, WARN, ERROR</option></select></div>
<div class="control"><label>Event type</label><select class="select" data-roadmap="Event type filtering is on the roadmap"><option>All event types</option></select></div>
<div class="control"><label>Job / trigger / group</label><input class="input" [value]="selectedTriggerKey?.name || ''" data-roadmap="Event text filtering is on the roadmap"></div>
<section class="preview"><h4>Supported now</h4><div>Per-trigger logs and progress through existing websocket topics.</div></section>
</aside>
</div>
</div>
<div class="page" [class.active]="activePage === 'scheduler'">
<div class="page-kicker">
<div><h2>Scheduler / Settings</h2><p>Supported lifecycle actions are wired to the backend. Cluster metadata, clear, delayed start, and state analytics are roadmap-gated.</p></div>
<div class="toolbar"><span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || 'LOADING' }}</span><button type="button" class="btn" (click)="refreshScheduler()">Refresh Metadata</button></div>
</div>
<div class="dashboard-grid">
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Lifecycle Controls</h2><span class="caption">Global actions affect the scheduler instance</span></div>
<div class="card-body command-panel"><div class="command-row"><button type="button" class="btn primary" (click)="startScheduler()">Start</button><button type="button" class="btn" data-roadmap="Delayed start is on the roadmap">Delayed Start 60s</button><button type="button" class="btn" (click)="standbyScheduler()">Standby</button><button type="button" class="btn" (click)="resumeScheduler()">Resume</button><button type="button" class="btn" data-roadmap="Pause all trigger groups is on the roadmap">Pause All</button><button type="button" class="btn danger" (click)="shutdownScheduler()">Shutdown</button><button type="button" class="btn danger" data-roadmap="Clear scheduler is on the roadmap">Clear Scheduler</button></div><div class="warning-box"><strong>Strong confirmation required</strong><span>Shutdown is supported and prompts before calling the backend. Clear remains roadmap-gated.</span></div></div>
</section>
<section class="card span-8">
<div class="card-header"><h2 class="card-title">Scheduler Metadata</h2><span class="chip accent">CURRENT API</span></div>
<div class="card-body summary-grid"><div class="field"><label>Scheduler name</label><strong>{{ scheduler?.name || '-' }}</strong></div><div class="field"><label>Instance ID</label><strong>{{ scheduler?.instanceId || '-' }}</strong></div><div class="field"><label>Status</label><strong>{{ scheduler?.status || '-' }}</strong></div><div class="field"><label>Trigger keys</label><strong>{{ triggerKeys.length }}</strong></div><div class="field"><label>Quartz version</label><strong>{{ scheduler?.quartzVersion || '-' }}</strong></div><div class="field"><label>Thread pool</label><strong>{{ scheduler?.threadPoolSize || '-' }}</strong></div><div class="field"><label>Job store</label><strong>{{ scheduler?.jobStoreClass || '-' }}</strong></div><div class="field"><label>Clustered</label><strong>{{ scheduler?.clustered ? 'YES' : 'NO' }}</strong></div></div>
</section>
<section class="card span-4">
<div class="card-header"><h2 class="card-title">Cluster Nodes</h2><span class="chip warn">ROADMAP</span></div>
<div class="card-body node-list" data-roadmap="Cluster node visibility is on the roadmap"><div class="node-row"><div><strong class="mono">{{ scheduler?.instanceId || 'local' }}</strong><div class="caption">local scheduler instance</div></div><span class="chip running">LOCAL</span></div><div class="node-row"><div><strong class="mono">remote nodes</strong><div class="caption">not exposed by backend</div></div><span class="chip warn">ROADMAP</span></div></div>
</section>
<section class="card span-12">
<div class="card-header"><h2 class="card-title">Global State Overview</h2><div class="toolbar"><span class="chip normal">{{ triggerKeys.length }} TRIGGERS</span><span class="chip warn">ANALYTICS ROADMAP</span></div></div>
<div class="table-wrap"><table><thead><tr><th>Area</th><th>Current state</th><th>Count</th><th>Representative key</th><th>Recommended action</th></tr></thead><tbody><tr><td>Scheduler</td><td><span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || '-' }}</span></td><td class="mono">1</td><td class="mono">{{ scheduler?.instanceId || '-' }}</td><td>Use lifecycle controls above.</td></tr><tr><td>Triggers</td><td><span class="chip normal">LISTED</span></td><td class="mono">{{ triggerKeys.length }}</td><td class="mono">{{ selectedTriggerKey?.name || '-' }}</td><td>Open Triggers for details or reschedule SimpleTriggers.</td></tr><tr data-roadmap="Misfire and error trigger analytics are on the roadmap"><td>Misfires / errors</td><td><span class="chip warn">ROADMAP</span></td><td class="mono">Roadmap</td><td class="mono">Roadmap</td><td>Backend analytics needed.</td></tr></tbody></table></div>
</section>
</div>
</div>
</section>
</main>
@if (wizardOpen || jobWizardOpen || calendarWizardOpen || detailDrawerOpen) {
<button type="button" class="drawer-backdrop" aria-label="Close drawer" (click)="closeDrawers()"></button>
}
@if (roadmapNotice || operationNotice || operationError) {
<section class="toast-overlay" [class.error]="operationError" [class.success]="operationNotice && !operationError">
<div class="toast-kicker">{{ operationError ? 'Action failed' : roadmapNotice ? 'Roadmap reminder' : 'Updated' }}</div>
<div class="toast-message">{{ operationError || roadmapNotice || operationNotice }}</div>
<button type="button" class="toast-close" (click)="dismissNotice()">Dismiss</button>
</section>
}
<aside class="wizard drawer" [class.drawer-open]="wizardOpen" aria-label="Trigger creation wizard">
<div class="wizard-header"><div><h2>{{ getWizardTitle() }}</h2><div class="caption">Simple, Cron, Daily Time Interval, and Calendar Interval triggers are supported.</div></div><button type="button" class="drawer-close" (click)="closeWizardDrawer()">Close</button></div>
<div class="stepper"><div class="step done"><span></span><span>Identity</span></div><div class="step active"><span></span><span>Type</span></div><div class="step done"><span></span><span>Schedule</span></div><div class="step done"><span></span><span>Advanced</span></div><div class="step active"><span></span><span>Preview</span></div></div>
<form class="wizard-form" (ngSubmit)="submitTriggerWizard()">
<div class="wizard-scroll">
@if (wizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ wizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Trigger key</label><div class="input-row"><input class="input" name="triggerName" [(ngModel)]="triggerDraft.triggerName" [readonly]="wizardMode === 'edit'" required><input class="input mono" name="group" [(ngModel)]="triggerDraft.group" list="trigger-groups" required></div><datalist id="trigger-groups">@for (group of getTriggerGroups(); track group) { <option [value]="group"></option> }</datalist><div class="help">Quartz groups are implicit namespaces. Type a new group to create it with this trigger.</div></div><div class="control"><label>Target type</label><select class="select" name="jobTargetType" [(ngModel)]="triggerDraft.jobTargetType"><option value="stored">Existing stored job</option><option value="class">New job from class</option></select></div>@if (triggerDraft.jobTargetType === 'stored') { <div class="control"><label>Stored job</label><select class="select" name="storedJobKey" [(ngModel)]="triggerDraft.storedJobKey" required>@for (job of getStoredJobOptions(); track job.value) { <option [value]="job.value">{{ job.label }}</option> }</select><div class="help">The trigger will call TriggerBuilder.forJob with this stored job key.</div></div> } @else { <div class="control"><label>Job class</label><select class="select" name="jobClass" [(ngModel)]="triggerDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select><div class="help">The backend will create an ephemeral job for this trigger.</div></div> }</div></section>
<section class="form-card"><h3>Trigger Type</h3><div class="form-section"><div class="radio-grid"><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'SIMPLE'" (click)="selectTriggerType('SIMPLE')"><strong>Simple</strong><span class="help">Repeat every fixed interval.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'CRON'" (click)="selectTriggerType('CRON')"><strong>Cron</strong><span class="help">Cron expression schedule.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'DAILY_TIME_INTERVAL'" (click)="selectTriggerType('DAILY_TIME_INTERVAL')"><strong>Daily Time</strong><span class="help">Run in a daily time window.</span></button><button type="button" class="type-option" [class.active]="triggerDraft.triggerType === 'CALENDAR_INTERVAL'" (click)="selectTriggerType('CALENDAR_INTERVAL')"><strong>Calendar Interval</strong><span class="help">Every N calendar units.</span></button></div></div></section>
<section class="form-card"><h3>Schedule Editor</h3><div class="form-section"><div class="control"><label>Start</label><input class="input mono" type="datetime-local" name="startDate" [(ngModel)]="triggerDraft.startDate"></div><div class="control"><label>End</label><input class="input mono" type="datetime-local" name="endDate" [(ngModel)]="triggerDraft.endDate"></div>@if (triggerDraft.triggerType === 'CRON') { <div class="control"><label>Cron expression</label><input class="input mono" name="cronExpression" [(ngModel)]="triggerDraft.cronExpression" required><div class="help">Quartz cron format, for example 0 0/5 * * * ?</div></div><div class="control"><label>Timezone</label><input class="input mono" name="cronTimeZone" [(ngModel)]="triggerDraft.timeZone"></div> } @else { <div class="control"><label>Interval</label><div class="input-row"><input class="input mono" type="number" min="1" name="repeatIntervalAmount" [(ngModel)]="triggerDraft.repeatIntervalAmount" required><select class="select" name="repeatIntervalUnit" [(ngModel)]="triggerDraft.repeatIntervalUnit"><option value="milliseconds" [disabled]="triggerDraft.triggerType !== 'SIMPLE'">milliseconds</option><option value="seconds">seconds</option><option value="minutes">minutes</option><option value="hours">hours</option><option value="days">days</option><option value="weeks" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">weeks</option><option value="months" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">months</option><option value="years" [disabled]="triggerDraft.triggerType !== 'CALENDAR_INTERVAL'">years</option></select></div></div> } @if (triggerDraft.triggerType === 'SIMPLE') { <div class="control"><label>Repeat count</label><input class="input mono" type="number" name="repeatCount" [(ngModel)]="triggerDraft.repeatCount" required><div class="help">Use -1 to repeat indefinitely.</div></div> } @if (triggerDraft.triggerType === 'DAILY_TIME_INTERVAL') { <div class="control"><label>Daily window</label><div class="input-row"><input class="input mono" name="startTimeOfDay" [(ngModel)]="triggerDraft.startTimeOfDay"><input class="input mono" name="endTimeOfDay" [(ngModel)]="triggerDraft.endTimeOfDay"></div></div><div class="control"><label>Days of week</label><div class="command-row">@for (day of [1,2,3,4,5,6,7]; track day) { <button type="button" class="btn compact" [class.primary]="isDayOfWeekSelected(day)" (click)="toggleDayOfWeek(day)">{{ day }}</button> }</div><div class="help">Quartz uses 1=Sunday through 7=Saturday.</div></div> } @if (triggerDraft.triggerType === 'CALENDAR_INTERVAL') { <label class="check-row"><input type="checkbox" name="preserveHour" [(ngModel)]="triggerDraft.preserveHourOfDayAcrossDaylightSavings"> Preserve hour across daylight saving</label><label class="check-row"><input type="checkbox" name="skipMissingHour" [(ngModel)]="triggerDraft.skipDayIfHourDoesNotExist"> Skip day if hour does not exist</label><div class="control"><label>Timezone</label><input class="input mono" name="calendarIntervalTimeZone" [(ngModel)]="triggerDraft.timeZone"></div> }</div></section>
<section class="form-card"><h3>Advanced</h3><div class="form-section"><div class="control"><label>Calendar</label><select class="select" name="calendarName" [(ngModel)]="triggerDraft.calendarName"><option value="">No calendar</option>@for (calendarName of getCalendarOptions(); track calendarName) { <option [value]="calendarName">{{ calendarName }}</option> }</select></div><div class="control"><label>Misfire policy</label><select class="select" name="misfireInstruction" [(ngModel)]="triggerDraft.misfireInstruction" required>@if (triggerDraft.triggerType === 'SIMPLE') { <option value="MISFIRE_INSTRUCTION_FIRE_NOW">FIRE_NOW</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE_NEXT_WITH_REMAINING_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE_NEXT_WITH_EXISTING_COUNT</option> } @else { <option value="FIRE_AND_PROCEED">FIRE_AND_PROCEED</option><option value="DO_NOTHING">DO_NOTHING</option><option value="IGNORE_MISFIRES">IGNORE_MISFIRES</option> }</select></div><div class="control"><label>JobDataMap override</label><div class="data-map-editor">@for (entry of triggerDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="triggerDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="triggerDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="triggerDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(triggerDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(triggerDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getTriggerDraftDataMapPreview() }}</pre></div></div></section>
<section class="preview"><h4>Plain-language summary</h4><div>Run <strong>{{ triggerDraft.jobTargetType === 'stored' ? triggerDraft.storedJobKey.replace('::', '.') : shortClassName(triggerDraft.jobClass) || 'selected job' }}</strong> as a <strong>{{ triggerDraft.triggerType }}</strong> trigger, starting at <strong>{{ triggerDraft.startDate || 'backend default start time' }}</strong>.</div><div class="fire-list">@for (fireTime of getFirePreview(); track fireTime) { <span>{{ fireTime }}</span> }</div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="resetWizard()">Reset</button><button type="submit" class="btn primary" [disabled]="wizardSubmitting || !canSubmitTrigger()">{{ wizardSubmitting ? 'Saving...' : getWizardCta() }}</button></div>
</form>
</aside>
<aside class="wizard drawer" [class.drawer-open]="jobWizardOpen" aria-label="Stored job editor">
<div class="wizard-header"><div><h2>{{ jobWizardMode === 'edit' ? 'Edit Stored Job' : 'New Stored Job' }}</h2><div class="caption">Stored jobs are durable Quartz JobDetails that triggers can launch by job key.</div></div><button type="button" class="drawer-close" (click)="closeJobWizardDrawer()">Close</button></div>
<form class="wizard-form" (ngSubmit)="submitJobWizard()">
<div class="wizard-scroll">
@if (jobWizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ jobWizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Job key</label><div class="input-row"><input class="input" name="jobName" [(ngModel)]="jobDraft.name" [readonly]="jobWizardMode === 'edit'" required><input class="input mono" name="jobGroup" [(ngModel)]="jobDraft.group" [readonly]="jobWizardMode === 'edit'" list="job-groups" required></div><datalist id="job-groups">@for (group of getJobGroups(); track group) { <option [value]="group"></option> }</datalist><div class="help">Groups are implicit. Typing a new group stores the job under that namespace.</div></div><div class="control"><label>Job class</label><select class="select" name="storedJobClass" [(ngModel)]="jobDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select></div><div class="control"><label>Description</label><input class="input" name="jobDescription" [(ngModel)]="jobDraft.description"></div></div></section>
<section class="form-card"><h3>Options</h3><div class="form-section"><label class="check-row"><input type="checkbox" name="jobDurable" [(ngModel)]="jobDraft.durable"> Store durably</label><label class="check-row"><input type="checkbox" name="jobRecovery" [(ngModel)]="jobDraft.requestsRecovery"> Requests recovery</label><div class="help">Durable jobs remain in the scheduler without active triggers and can be selected later by SimpleTriggers.</div></div></section>
<section class="form-card"><h3>JobDataMap</h3><div class="form-section"><div class="data-map-editor">@for (entry of jobDraft.jobDataMapEntries; track $index) { <div class="data-map-row"><input class="input mono" name="jobDataKey{{$index}}" placeholder="key" [(ngModel)]="entry.key"><select class="select" name="jobDataType{{$index}}" [(ngModel)]="entry.type"><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="json">json</option><option value="null">null</option></select><input class="input mono" name="jobDataValue{{$index}}" placeholder="value" [(ngModel)]="entry.value" [readonly]="entry.type === 'null'"><button type="button" class="btn danger compact" (click)="removeJobDataMapEntry(jobDraft.jobDataMapEntries, $index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addJobDataMapEntry(jobDraft.jobDataMapEntries)">Add Data</button><pre class="code-block">{{ getJobDraftDataMapPreview() }}</pre></div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="closeJobWizardDrawer()">Cancel</button><button type="submit" class="btn primary" [disabled]="jobWizardSubmitting || !canSubmitJob()">{{ jobWizardSubmitting ? 'Saving...' : jobWizardMode === 'edit' ? 'Save Job' : 'Create Job' }}</button></div>
</form>
</aside>
<aside class="wizard drawer" [class.drawer-open]="calendarWizardOpen" aria-label="Calendar editor">
<div class="wizard-header"><div><h2>{{ calendarWizardMode === 'edit' ? 'Edit Calendar' : 'New Calendar' }}</h2><div class="caption">Quartz calendars exclude times from trigger schedules.</div></div><button type="button" class="drawer-close" (click)="closeCalendarWizardDrawer()">Close</button></div>
<form class="wizard-form" (ngSubmit)="submitCalendarWizard()">
<div class="wizard-scroll">
@if (calendarWizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ calendarWizardError }}</span></div> }
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Calendar name</label><input class="input mono" name="calendarNameInput" [(ngModel)]="calendarDraft.name" [readonly]="calendarWizardMode === 'edit'" required></div><div class="control"><label>Type</label><select class="select" name="calendarType" [(ngModel)]="calendarDraft.type"><option value="WEEKLY">Weekly</option><option value="MONTHLY">Monthly</option><option value="ANNUAL">Annual</option><option value="HOLIDAY">Holiday</option><option value="DAILY">Daily</option><option value="CRON">Cron</option></select></div><div class="control"><label>Description</label><input class="input" name="calendarDescription" [(ngModel)]="calendarDraft.description"></div></div></section>
<section class="form-card"><h3>Rules</h3><div class="form-section">@if (getCalendarRuleMode() === 'weekdays') { <div class="control"><label>Excluded days</label><div class="command-row">@for (day of [1,2,3,4,5,6,7]; track day) { <button type="button" class="btn compact" [class.primary]="calendarDraft.excludedDaysOfWeek.includes(day)" (click)="toggleCalendarWeekday(day)">{{ day }}</button> }</div><div class="help">Quartz uses 1=Sunday through 7=Saturday.</div></div> } @if (getCalendarRuleMode() === 'monthdays') { <div class="control"><label>Excluded month days</label><div class="command-row">@for (day of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31]; track day) { <button type="button" class="btn compact" [class.primary]="calendarDraft.excludedDaysOfMonth.includes(day)" (click)="toggleCalendarMonthday(day)">{{ day }}</button> }</div></div> } @if (getCalendarRuleMode() === 'dates') { <div class="control"><label>Excluded dates</label><div class="data-map-editor">@for (date of calendarDraft.excludedDates; track $index) { <div class="data-map-row"><input class="input mono" type="datetime-local" name="calendarDate{{$index}}" [(ngModel)]="calendarDraft.excludedDates[$index]"><button type="button" class="btn danger compact" (click)="removeCalendarDate($index)">Remove</button></div> }</div><button type="button" class="btn" (click)="addCalendarDate()">Add Date</button></div> } @if (getCalendarRuleMode() === 'timeRange') { <div class="control"><label>Excluded time range</label><div class="input-row"><input class="input mono" name="rangeStartingTime" [(ngModel)]="calendarDraft.rangeStartingTime"><input class="input mono" name="rangeEndingTime" [(ngModel)]="calendarDraft.rangeEndingTime"></div></div><label class="check-row"><input type="checkbox" name="invertTimeRange" [(ngModel)]="calendarDraft.invertTimeRange"> Invert time range</label> } @if (getCalendarRuleMode() === 'cron') { <div class="control"><label>Cron exclusion expression</label><input class="input mono" name="calendarCron" [(ngModel)]="calendarDraft.cronExpression" required></div><div class="control"><label>Timezone</label><input class="input mono" name="calendarTimeZone" [(ngModel)]="calendarDraft.timeZone"></div> }</div></section>
</div>
<div class="wizard-footer"><button type="button" class="btn" (click)="closeCalendarWizardDrawer()">Cancel</button><button type="submit" class="btn primary" [disabled]="calendarWizardSubmitting || !canSubmitCalendar()">{{ calendarWizardSubmitting ? 'Saving...' : calendarWizardMode === 'edit' ? 'Save Calendar' : 'Create Calendar' }}</button></div>
</form>
</aside>
</div>

View File

@@ -1 +1,353 @@
:host {
--bg: oklch(98% 0.005 250);
--surface: oklch(100% 0 0);
--fg: oklch(22% 0.02 240);
--muted: oklch(50% 0.018 240);
--border: oklch(90% 0.008 240);
--accent: oklch(56% 0.19 302);
--success: oklch(58% 0.16 145);
--warning: oklch(72% 0.15 82);
--danger: oklch(58% 0.19 28);
--info: oklch(58% 0.18 255);
--radius: 8px;
display: block;
min-height: 100vh;
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
}
* { box-sizing: border-box; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.qm-app {
display: grid;
grid-template-columns: 248px minmax(780px, 1fr);
min-height: 100vh;
background: var(--bg);
}
.qm-app.object-mode { grid-template-columns: 248px minmax(780px, 1fr); }
.rail {
border-right: 1px solid var(--border);
background: oklch(99% 0.003 250);
padding: 18px 14px;
display: flex;
flex-direction: column;
gap: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 14px;
border-bottom: 1px solid var(--border);
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 7px;
display: grid;
place-items: center;
color: white;
background: var(--accent);
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-size: 12px;
font-weight: 700;
}
.brand-title { font-weight: 700; font-size: 14px; line-height: 1.15; }
.brand-subtitle, .caption, .help { color: var(--muted); font-size: 12px; }
.brand-subtitle, .caption, .mono, .kv span:last-child, .chip, .card-title, .field strong, .code-block, .fire-list { font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; }
.caption { font-size: 11px; }
.nav { display: flex; flex-direction: column; gap: 3px; }
.nav button {
border: 0;
background: transparent;
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
padding: 9px 10px;
border-radius: 7px;
text-align: left;
}
.nav button.active {
background: oklch(56% 0.19 302 / 0.10);
color: var(--fg);
box-shadow: inset 3px 0 0 var(--accent);
}
.nav svg { width: 17px; height: 17px; stroke-width: 1.8; }
.rail-card {
margin-top: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
padding: 12px;
}
.rail-card h3, .filter-panel h3 {
margin: 0 0 7px;
font-size: 12px;
text-transform: uppercase;
color: var(--muted);
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
font-weight: 700;
}
.connection { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 12px; }
.main { min-width: 0; display: flex; flex-direction: column; }
.topbar {
position: sticky;
top: 0;
z-index: 3;
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: center;
min-height: 60px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: oklch(99% 0.002 250 / 0.92);
backdrop-filter: blur(14px);
}
.scheduler-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; }
.scheduler-title { min-width: 210px; }
h1 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: 0; }
h2 { margin: 0; }
.kv { display: grid; gap: 2px; min-width: 118px; border: 0; background: transparent; padding: 0; color: inherit; text-align: left; }
.kv span:first-child { color: var(--muted); font-size: 11px; }
.kv span:last-child { font-size: 12px; white-space: nowrap; }
.kv-button span:last-child { color: var(--warning); }
.actions, .toolbar, .command-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.compact-actions { gap: 7px; }
.btn {
border: 1px solid var(--border);
border-radius: 7px;
padding: 8px 11px;
min-height: 36px;
background: var(--surface);
color: var(--fg);
display: inline-flex;
align-items: center;
gap: 7px;
white-space: nowrap;
}
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.compact { min-height: 32px; padding: 6px 10px; font-size: 12px; }
.btn.danger { color: var(--danger); border-color: oklch(58% 0.19 28 / 0.35); background: oklch(58% 0.19 28 / 0.06); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.toast-overlay {
position: fixed;
top: 18px;
right: 18px;
z-index: 90;
width: min(460px, calc(100vw - 36px));
padding: 16px 46px 16px 16px;
border: 1px solid oklch(72% 0.15 82 / 0.55);
border-left: 5px solid var(--warning);
background: oklch(99% 0.02 82);
color: var(--fg);
border-radius: 12px;
box-shadow: 0 22px 60px oklch(22% 0.02 240 / 0.20);
}
.toast-overlay.success { border-color: oklch(58% 0.16 145 / 0.36); border-left-color: var(--success); background: oklch(98% 0.02 145); }
.toast-overlay.error { border-color: oklch(58% 0.19 28 / 0.40); border-left-color: var(--danger); background: oklch(98% 0.02 28); }
.toast-kicker { font: 800 12px 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; letter-spacing: 0.05em; text-transform: uppercase; }
.toast-message { margin-top: 6px; line-height: 1.45; }
.toast-close { position: absolute; top: 10px; right: 10px; border: 0; background: transparent; color: var(--muted); }
.content { padding: 18px 20px 22px; display: grid; gap: 16px; }
.page { display: none; }
.page.active { display: grid; gap: 16px; }
.page-kicker { display: flex; justify-content: space-between; align-items: flex-end; gap: 14px; margin-bottom: 2px; }
.page-kicker h2 { font-size: 19px; }
.page-kicker p { margin: 4px 0 0; max-width: 760px; color: var(--muted); font-size: 13px; }
.dashboard-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 14px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); min-width: 0; overflow: hidden; }
.card-header { min-height: 48px; padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card-title { font-size: 12px; text-transform: uppercase; color: var(--muted); font-weight: 700; }
.card-body { padding: 14px; }
.span-3 { grid-column: span 3; }
.span-4 { grid-column: span 4; }
.span-5 { grid-column: span 5; }
.span-7 { grid-column: span 7; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: span 12; }
.scheduler-command-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); gap: 14px; align-items: stretch; }
.command-panel { display: grid; gap: 12px; }
.metadata-grid, .summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.summary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.metric { display: grid; gap: 7px; min-height: 112px; }
.metric-value { font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 27px; font-weight: 720; font-variant-numeric: tabular-nums; }
.compact-metric { font-size: 22px; }
.metric-label { color: var(--muted); font-size: 12px; }
.metric-line { height: 5px; border-radius: 999px; background: var(--border); overflow: hidden; margin-top: auto; }
.metric-line > span { display: block; height: 100%; background: var(--success); width: var(--w); }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--border);
padding: 0 8px;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
background: var(--surface);
color: var(--muted);
}
.chip::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.chip.running, .chip.normal, .chip.success { color: var(--success); background: oklch(58% 0.16 145 / 0.08); border-color: oklch(58% 0.16 145 / 0.25); }
.chip.paused, .chip.warn { color: var(--warning); background: oklch(72% 0.15 82 / 0.12); border-color: oklch(72% 0.15 82 / 0.30); }
.chip.error, .chip.danger { color: var(--danger); background: oklch(58% 0.19 28 / 0.08); border-color: oklch(58% 0.19 28 / 0.25); }
.chip.blocked { color: var(--info); background: oklch(58% 0.18 255 / 0.08); border-color: oklch(58% 0.18 255 / 0.25); }
.chip.accent { color: var(--accent); background: oklch(56% 0.19 302 / 0.08); border-color: oklch(56% 0.19 302 / 0.25); }
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; }
th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
th { color: var(--muted); font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-weight: 650; background: oklch(98% 0.004 250); }
.selectable:hover { background: oklch(56% 0.19 302 / 0.035); }
tr.selected { background: oklch(56% 0.19 302 / 0.06); box-shadow: inset 3px 0 0 var(--accent); }
.split { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 420px; }
.object-mode .split { grid-template-columns: minmax(0, 1fr); }
.detail { background: oklch(99% 0.003 250); padding: 14px; display: flex; flex-direction: column; gap: 14px; }
.detail h2 { font-size: 17px; }
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
.drawer-close { border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted); padding: 6px 10px; font-size: 12px; }
.drawer-backdrop { position: fixed; inset: 0; z-index: 70; border: 0; background: oklch(22% 0.02 240 / 0.32); backdrop-filter: blur(2px); }
.drawer {
position: fixed;
top: 0;
right: 0;
z-index: 80;
width: min(460px, 100vw);
height: 100vh;
max-height: 100vh;
overflow: auto;
border-left: 1px solid var(--border);
box-shadow: -24px 0 70px oklch(22% 0.02 240 / 0.22);
transform: translateX(104%);
transition: transform 180ms ease;
}
.drawer.drawer-open { transform: translateX(0); }
.detail-drawer { width: min(430px, 100vw); }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); overflow-x: auto; }
.tab { padding: 8px 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; }
.tab.active { color: var(--fg); border-color: var(--accent); }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field { display: grid; gap: 4px; padding: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); min-width: 0; text-align: left; color: inherit; }
.field-button { cursor: pointer; }
.field label { color: var(--muted); font-size: 11px; }
.field strong { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress-card { display: grid; gap: 10px; }
.progress-line { height: 8px; border-radius: 999px; background: var(--border); overflow: hidden; }
.progress-line span { display: block; height: 100%; background: var(--success); }
.preview { display: grid; gap: 9px; padding: 13px; border-radius: var(--radius); background: oklch(56% 0.19 302 / 0.07); border: 1px solid oklch(56% 0.19 302 / 0.18); }
.preview h4 { margin: 0; font-size: 13px; }
.fire-list { display: grid; gap: 5px; font-size: 12px; }
.warning-box, .danger-zone { border: 1px solid oklch(58% 0.19 28 / 0.30); background: oklch(58% 0.19 28 / 0.07); border-radius: 7px; padding: 10px; display: grid; gap: 5px; }
.warning-box strong, .danger-zone strong { color: var(--danger); font-size: 12px; }
.danger-zone { border-radius: var(--radius); padding: 12px; }
.code-block { margin: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: oklch(97% 0.006 250); font-size: 12px; overflow: auto; white-space: pre-wrap; }
.stream { display: grid; grid-template-columns: 1fr; gap: 0; max-height: 310px; overflow: auto; }
.tall-stream { max-height: 560px; }
.stream-row { display: grid; grid-template-columns: 92px 78px 112px 140px 1fr; gap: 10px; align-items: center; padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
.stream-row:first-child { background: oklch(98% 0.004 250); color: var(--muted); font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-weight: 650; position: sticky; top: 0; z-index: 1; }
.muted-row { color: var(--muted); }
.search { min-width: 220px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); height: 32px; padding: 0 12px; color: var(--muted); font-size: 12px; }
.mini-chart { height: 154px; display: grid; grid-template-columns: repeat(18, 1fr); align-items: end; gap: 5px; border-bottom: 1px solid var(--border); padding-top: 18px; }
.bar { background: color-mix(in oklch, var(--success), white 38%); border-radius: 4px 4px 0 0; height: var(--h); min-height: 12px; }
.bar.warn { background: color-mix(in oklch, var(--warning), white 35%); }
.bar.error { background: color-mix(in oklch, var(--danger), white 35%); }
.top-space { margin-top: 14px; }
.two-column { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; }
.filter-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 12px; display: grid; gap: 12px; align-content: start; }
.control { display: grid; gap: 6px; }
.control label { font-size: 12px; color: var(--muted); }
.input, .select, .textarea { width: 100%; min-width: 0; border: 1px solid var(--border); border-radius: 6px; background: oklch(99% 0.002 250); min-height: 38px; padding: 8px 10px; color: var(--fg); outline: none; }
.textarea { min-height: 70px; resize: vertical; }
.compact-select { width: auto; min-width: 150px; min-height: 32px; padding-block: 5px; }
.check-row { display: flex; gap: 8px; align-items: center; color: var(--fg); }
.data-map-editor { display: grid; gap: 8px; }
.data-map-row { display: grid; grid-template-columns: minmax(90px, 1fr) 104px minmax(110px, 1fr) auto; gap: 8px; align-items: start; }
.calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; }
.calendar-cell { min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 6px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 11px; color: var(--muted); }
.calendar-cell.excluded { color: var(--danger); background: oklch(58% 0.19 28 / 0.06); border-color: oklch(58% 0.19 28 / 0.25); }
.roadmap-copy { margin: 0 0 14px; color: var(--muted); }
.compact-roadmap { align-items: start; }
.node-list { display: grid; gap: 8px; }
.node-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); }
.wizard { background: oklch(99% 0.002 250); display: flex; flex-direction: column; width: min(620px, 100vw); height: 100dvh; overflow: hidden; }
.wizard-header { min-height: 76px; padding: 16px 18px; border-bottom: 1px solid var(--border); background: var(--surface); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.wizard-header h2 { font-size: 17px; }
.stepper { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.step { display: grid; gap: 5px; color: var(--muted); font-size: 11px; }
.step span:first-child { height: 4px; border-radius: 999px; background: var(--border); }
.step.done span:first-child, .step.active span:first-child { background: var(--accent); }
.step.active { color: var(--fg); font-weight: 650; }
.wizard-form { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; }
.wizard-scroll { flex: 1 1 auto; min-height: 0; padding: 16px 18px; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 14px; }
.wizard-scroll > * { flex: 0 0 auto; }
.form-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
.form-card h3 { margin: 0; padding: 12px 13px; border-bottom: 1px solid var(--border); font-size: 12px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; color: var(--muted); text-transform: uppercase; }
.form-section { padding: 13px; display: grid; gap: 12px; }
.input-row { display: grid; grid-template-columns: 1fr 118px; gap: 8px; }
.radio-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.type-option { border: 1px solid var(--border); border-radius: 7px; padding: 10px; display: grid; gap: 4px; background: oklch(99% 0.002 250); text-align: left; color: inherit; min-width: 0; }
.type-option.active { border-color: oklch(56% 0.19 302 / 0.55); box-shadow: inset 0 0 0 1px oklch(56% 0.19 302 / 0.22); background: oklch(56% 0.19 302 / 0.06); }
.type-option strong { font-size: 12px; }
.wizard-footer { margin-top: auto; display: flex; justify-content: space-between; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--border); background: var(--surface); }
@media (max-width: 1280px) {
.qm-app { grid-template-columns: 78px minmax(680px, 1fr); }
.qm-app.object-mode { grid-template-columns: 78px minmax(680px, 1fr); }
.brand-title, .brand-subtitle, .nav span, .rail-card { display: none; }
.rail { align-items: center; }
.nav button { justify-content: center; }
.brand { padding-inline: 0; border-bottom: 0; }
}
@media (max-width: 960px) {
.qm-app, .qm-app.object-mode { grid-template-columns: 1fr; }
.rail { position: sticky; top: 0; z-index: 5; border-right: 0; border-bottom: 1px solid var(--border); flex-direction: row; align-items: center; overflow-x: auto; padding: 10px; }
.brand-title, .brand-subtitle, .nav span { display: block; }
.brand { border-bottom: 0; padding: 0; min-width: 190px; }
.nav { flex-direction: row; }
.topbar, .page-kicker, .scheduler-command-grid, .two-column, .split, .object-mode .split { grid-template-columns: 1fr; }
.drawer { width: min(420px, 100vw); }
.span-3, .span-4, .span-5, .span-7, .span-8, .span-12 { grid-column: span 12; }
.metadata-grid, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.content { padding: 12px; }
.dashboard-grid { grid-template-columns: 1fr; }
.span-3, .span-4, .span-5, .span-7, .span-8, .span-12 { grid-column: span 1; }
.metadata-grid, .summary-grid, .field-grid, .radio-grid, .input-row, .data-map-row { grid-template-columns: 1fr; }
.stepper { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.stream-row { grid-template-columns: 1fr; gap: 4px; }
.toast-overlay { top: 10px; right: 10px; width: calc(100vw - 20px); }
.page-kicker { align-items: stretch; }
}

View File

@@ -1,4 +1,4 @@
<div fxLayout="column" fxLayoutAlign="center" style="text-align: center">
<div class="flex flex-column justify-center" style="text-align: center">
<div>
<div>
<p style="font-size: 4em; margin-bottom: 0">Not Found!</p>

View File

@@ -1,7 +1,8 @@
import { Component } from '@angular/core';
@Component({
templateUrl: './not-found.component.html'
templateUrl: './not-found.component.html',
standalone: false
})
export class NotFoundComponent {

View File

@@ -1,4 +1,4 @@
import { enableProdMode } from '@angular/core';
import { enableProdMode, provideZoneChangeDetection } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
@@ -8,4 +8,6 @@ if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule);
platformBrowserDynamic().bootstrapModule(AppModule, {
applicationProviders: [provideZoneChangeDetection()],
});

View File

@@ -1,75 +1,3 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
*/
import 'zone.js';
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
// import 'core-js/es6/symbol';
// import 'core-js/es6/object';
// import 'core-js/es6/function';
// import 'core-js/es6/parse-int';
// import 'core-js/es6/parse-float';
// import 'core-js/es6/number';
// import 'core-js/es6/math';
// import 'core-js/es6/string';
// import 'core-js/es6/date';
// import 'core-js/es6/array';
// import 'core-js/es6/regexp';
// import 'core-js/es6/map';
// import 'core-js/es6/set';
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/** IE10 and IE11 requires the following to support `@angular/animation`. */
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/** Evergreen browsers require these. **/
import 'core-js/es6/reflect';
/** ALL Firefox browsers require the following to support `@angular/animation`. **/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/***************************************************************************************************
* Zone JS is required by Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
(window as any).global = window
/***************************************************************************************************
* APPLICATION IMPORTS
*/
/**
* Date, currency, decimal and percent pipes.
* Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10
*/
// import 'intl'; // Run `npm install --save intl`.
/***************************************************************************************************
* MATERIAL 2
*/
import 'hammerjs/hammer';
(window as any).global = window;

View File

@@ -1,5 +1,6 @@
/* You can add global styles to this file, and also import other style files */
@import '~@angular/material/prebuilt-themes/deeppurple-amber.css';
@import '@angular/material/prebuilt-themes/deeppurple-amber.css';
@import '@danielmoncada/angular-datetime-picker/assets/style/picker.min.css';
@import "animate.css";
html {
@@ -12,3 +13,132 @@ body {
flex:1;
background-color: #f1f1f1;
}
/**
TODO: Remove the below utility classes once tailwind is integrated.
*/
.font-13 {
font-size: 13px;
}
.font-large {
font-size: large;
}
.font-larger {
font-size: larger;
}
.justify-space-between {
justify-content: space-between;
}
.justify-space-around {
justify-content: space-around;
}
.justify-space-evenly {
justify-content: space-evenly;
}
.justify-center {
justify-content: center;
}
.justify-flex-end {
justify-content: flex-end;
}
.flex {
display: flex;
}
.flex-row {
flex-direction: row;
}
.flex-column {
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.flex-none {
flex: 0 0 auto;
}
.h-100 {
height: 100%;
}
.min-h-100 {
min-height: 100%;
}
.max-h-100 {
max-height: 100%;
}
.w-100 {
width: 100%;
}
.gap-6 {
gap: 6px;
}
.gap-10 {
gap: 10px;
}
.gap-12 {
gap: 12px;
}
.gap-30 {
gap: 30px;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-y-auto {
overflow-y: auto;
}
.pb-16 {
padding-bottom: 16px !important;
}
.mdc-list-item__primary-text {
font-size: 0.8em !important;
}
.font-size-14 {
font-size: 14px;
}
.font-weight-500 {
font-weight: 500;
}
.display-block {
display: block;
}
.line-height-100 {
line-height: 100%;
}
.align-items-center {
align-items: center;
}
.align-items-stretch {
align-items: stretch;
}
@media (max-width: 599px) {
.log-row {
flex-direction: column;
}
}

Some files were not shown because too many files have changed in this diff Show More