mirror of
https://github.com/fabioformosa/quartz-manager.git
synced 2026-05-15 06:10:29 +09:00
Compare commits
103 Commits
dependabot
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc433bb531 | ||
|
|
501ef6c062 | ||
|
|
b6529b453a | ||
|
|
40d8c952a0 | ||
|
|
c1511b54f8 | ||
|
|
9a50949fcc | ||
|
|
699e661d81 | ||
|
|
93990a5994 | ||
|
|
82e684f0a7 | ||
|
|
7d481247bc | ||
|
|
e24c5bc62a | ||
|
|
29fff2a6cd | ||
|
|
2a20b930f0 | ||
|
|
b0ba230abe | ||
|
|
1e48e1803f | ||
|
|
e90648c027 | ||
|
|
23417fa6a2 | ||
|
|
57d5ebd641 | ||
|
|
da8c9d5707 | ||
|
|
6b245c7eec | ||
|
|
d7653dc73e | ||
|
|
e6a7b35f6a | ||
|
|
f6e02ae181 | ||
|
|
f6d6cd16e7 | ||
|
|
87901fe6a7 | ||
|
|
3088a2fec1 | ||
|
|
bdd0caa026 | ||
|
|
068b0eed34 | ||
|
|
b2f9692815 | ||
|
|
5fc6c56409 | ||
|
|
d7a78c57ae | ||
|
|
31658416f5 | ||
|
|
1d81e88684 | ||
|
|
f55a58b100 | ||
|
|
902542e480 | ||
|
|
c198d32bd5 | ||
|
|
56d9f5d94f | ||
|
|
45f66d51fe | ||
|
|
95769248a3 | ||
|
|
dcbf3eb277 | ||
|
|
fab977fd7d | ||
|
|
9d2a01ebbe | ||
|
|
9a0789cab0 | ||
|
|
e5a6b8b32b | ||
|
|
4537c8e304 | ||
|
|
82ac821b34 | ||
|
|
0a4a31ae65 | ||
|
|
3b325536e8 | ||
|
|
307c6eab98 | ||
|
|
b1ff70265f | ||
|
|
226296737d | ||
|
|
a5750d1d0d | ||
|
|
f71c9b20ab | ||
|
|
9cc55492dc | ||
|
|
6d36e4620c | ||
|
|
4d5e8f62c3 | ||
|
|
8ba33f25b4 | ||
|
|
7a742d5aea | ||
|
|
abd25d6158 | ||
|
|
9f46e52564 | ||
|
|
e6927209e5 | ||
|
|
63fbedbdc8 | ||
|
|
6ec886686f | ||
|
|
f96e356c8a | ||
|
|
c646624e45 | ||
|
|
0148056b1f | ||
|
|
e9542352b5 | ||
|
|
7cef35517b | ||
|
|
bc0619a92a | ||
|
|
2debf6c63f | ||
|
|
baa01d10cb | ||
|
|
adb4864c85 | ||
|
|
c6f10e04eb | ||
|
|
ad6a61f3df | ||
|
|
5cb73019de | ||
|
|
8a8e878e47 | ||
|
|
fae82e1d4e | ||
|
|
e0011913c2 | ||
|
|
a59b6a6c96 | ||
|
|
68aaab6ac4 | ||
|
|
c75190513a | ||
|
|
1421c52c34 | ||
|
|
b2942737af | ||
|
|
a1d8b12e98 | ||
|
|
45d6a4c59a | ||
|
|
e6f6fb5f06 | ||
|
|
13c438d097 | ||
|
|
412e455907 | ||
|
|
75d630aad0 | ||
|
|
2105e289ac | ||
|
|
9eddc0b1fd | ||
|
|
fa4ede5179 | ||
|
|
3aa672031a | ||
|
|
82ca186bff | ||
|
|
727a11fcea | ||
|
|
7106dc0fbb | ||
|
|
a2c8ecb227 | ||
|
|
e4c771e364 | ||
|
|
261dd8b624 | ||
|
|
ac63576704 | ||
|
|
a3b92443c4 | ||
|
|
527ee1200a | ||
|
|
7fd174b313 |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# .dockerignore
|
||||
quartz-manager-frontend/node_modules
|
||||
20
.github/workflows/maven-release.yml
vendored
20
.github/workflows/maven-release.yml
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/maven.yml
vendored
8
.github/workflows/maven.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/npm.yml
vendored
6
.github/workflows/npm.yml
vendored
@@ -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'
|
||||
|
||||
15
.github/workflows/sonar-java.yml
vendored
15
.github/workflows/sonar-java.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
/.project
|
||||
.idea
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
@@ -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
34
Dockerfile
Normal 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"]
|
||||
20
README.md
20
README.md
@@ -1,8 +1,9 @@
|
||||
[](https://github.com/fabioformosa/quartz-manager/actions/workflows/maven.yml)
|
||||
[](https://github.com/fabioformosa/quartz-manager/actions/workflows/npm.yml)
|
||||
[](https://maven-badges.herokuapp.com/maven-central/it.fabioformosa.quartz-manager/quartz-manager-starter-api)
|
||||
[](https://sonarcloud.io/summary/new_code?id=fabioformosa_quartz-manager) [](https://sonarcloud.io/summary/new_code?id=fabioformosa_quartz-manager) [](https://sonarcloud.io/summary/new_code?id=fabioformosa_quartz-manager) [](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
45
cloudbuild.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
19
quartz-manager-frontend/.eslintrc.sonar.json
Normal file
19
quartz-manager-frontend/.eslintrc.sonar.json
Normal 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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
30
quartz-manager-frontend/eslint.sonar.config.mjs
Normal file
30
quartz-manager-frontend/eslint.sonar.config.mjs
Normal 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'
|
||||
}
|
||||
}
|
||||
];
|
||||
20
quartz-manager-frontend/jest.config.js
Normal file
20
quartz-manager-frontend/jest.config.js
Normal 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$)/)'
|
||||
]
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
import 'jest-preset-angular/setup-jest';
|
||||
import {setupZoneTestEnv} from 'jest-preset-angular/setup-env/zone/index.mjs';
|
||||
|
||||
setupZoneTestEnv();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
41349
quartz-manager-frontend/package-lock.json
generated
41349
quartz-manager-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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"/>
|
||||
Quartz Manager
|
||||
</a>
|
||||
<!-- Hand crafted with love by -->
|
||||
<!-- <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 -->
|
||||
<!-- <a href="https://github.com/fabioformosa" style="color: rgba(255, 255, 255, 0.870588);">Fabio Formosa</a>-->
|
||||
</mat-toolbar>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"> / {{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"> / {{ 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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> <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
|
||||
> <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>
|
||||
|
||||
@@ -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 */
|
||||
* {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
* {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
24
quartz-manager-frontend/src/app/model/calendar.model.ts
Normal file
24
quartz-manager-frontend/src/app/model/calendar.model.ts
Normal 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;
|
||||
}
|
||||
@@ -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.`
|
||||
]
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ScheduledJobCommand {
|
||||
jobClass: string;
|
||||
description: string;
|
||||
durable: boolean;
|
||||
requestsRecovery: boolean;
|
||||
jobDataMap: {[key: string]: unknown};
|
||||
}
|
||||
12
quartz-manager-frontend/src/app/model/scheduled-job.model.ts
Normal file
12
quartz-manager-frontend/src/app/model/scheduled-job.model.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
34
quartz-manager-frontend/src/app/services/calendar.service.ts
Normal file
34
quartz-manager-frontend/src/app/services/calendar.service.ts
Normal 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});
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
46
quartz-manager-frontend/src/app/services/job.service.spec.ts
Normal file
46
quartz-manager-frontend/src/app/services/job.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
11
quartz-manager-frontend/src/app/services/rx-stomp.service.ts
Normal file
11
quartz-manager-frontend/src/app/services/rx-stomp.service.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user