Browse Source

Merge branch 'feature/server/disjunct-checks' of hostbbq/hostbbq-monitoring into master

tunefish 2 years ago
parent
commit
0385c111d5
64 changed files with 1528 additions and 202 deletions
  1. 69 0
      Readme.md
  2. 4 1
      common/types/http-check-config.d.ts
  3. 2 0
      ng/package.json
  4. 5 1
      ng/src/app/app-routing.module.ts
  5. 18 1
      ng/src/app/app.component.html
  6. 3 1
      ng/src/app/app.component.ts
  7. 18 2
      ng/src/app/app.module.ts
  8. 13 0
      ng/src/app/components/confirm-modal/confirm-modal.component.html
  9. 0 0
      ng/src/app/components/confirm-modal/confirm-modal.component.scss
  10. 23 0
      ng/src/app/components/confirm-modal/confirm-modal.component.spec.ts
  11. 63 0
      ng/src/app/components/confirm-modal/confirm-modal.component.ts
  12. 23 0
      ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.html
  13. 0 0
      ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.scss
  14. 23 0
      ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.spec.ts
  15. 47 0
      ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.ts
  16. 6 0
      ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.html
  17. 0 0
      ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.scss
  18. 23 0
      ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.spec.ts
  19. 13 0
      ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.ts
  20. 32 0
      ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.html
  21. 0 0
      ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.scss
  22. 23 0
      ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.spec.ts
  23. 48 0
      ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.ts
  24. 26 0
      ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.html
  25. 0 0
      ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.scss
  26. 23 0
      ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.spec.ts
  27. 49 0
      ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.ts
  28. 14 0
      ng/src/app/components/service-check-editor/service-check-editor.component.ts
  29. 11 0
      ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.html
  30. 0 0
      ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.scss
  31. 23 0
      ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.spec.ts
  32. 25 0
      ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.ts
  33. 114 46
      ng/src/app/components/service-check-form/service-check-form.component.html
  34. 0 4
      ng/src/app/components/service-check-form/service-check-form.component.scss
  35. 54 44
      ng/src/app/components/service-check-form/service-check-form.component.ts
  36. 4 24
      ng/src/app/pages/admin-panel/admin-panel.component.html
  37. 6 31
      ng/src/app/pages/admin-panel/admin-panel.component.ts
  38. 64 0
      ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.html
  39. 0 0
      ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.scss
  40. 23 0
      ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.spec.ts
  41. 186 0
      ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.ts
  42. 1 1
      ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.html
  43. 16 0
      ng/src/app/services/component.service.spec.ts
  44. 37 0
      ng/src/app/services/component.service.ts
  45. 13 1
      ng/src/app/services/service-api.service.ts
  46. 16 0
      ng/src/app/services/toast.service.spec.ts
  47. 53 0
      ng/src/app/services/toast.service.ts
  48. BIN
      ng/src/favicon.ico
  49. 20 1
      ng/src/styles.scss
  50. 3 1
      ng/tsconfig.app.json
  51. 3 0
      ng/tsconfig.json
  52. 2 1
      ng/tsconfig.spec.json
  53. 32 0
      server/docs/Monitoring.postman_collection.json
  54. 1 1
      server/package.json
  55. 11 5
      server/src/ctrl/database.class.ts
  56. 40 16
      server/src/ctrl/db-migration.class.ts
  57. 7 0
      server/src/ctrl/health-check-data-provider.interface.ts
  58. 77 18
      server/src/ctrl/http-check-controller.class.ts
  59. 3 0
      server/src/lib/migration-runner.interface.ts
  60. 30 0
      server/src/migrations/202301302212_website_healtcheck_disjunctive_checks.ts
  61. 15 0
      server/src/migrations/202302131250_website_healthcheck_fix2.sql
  62. 52 1
      server/src/webhdl/services-api-handler.class.ts
  63. 2 1
      server/tsconfig.json
  64. 16 0
      server/tsconfig.mig.json

+ 69 - 0
Readme.md

@@ -10,6 +10,75 @@ curl -fsSL https://gogs.hostbbq.com/hostbbq/hostbbq-monitoring/raw/master/daemon
 
 ## Monitoring Server & Web UI
 
+### Local Development
+
+> ℹ Für Entwicklungszwecke kann der Monitoring Server auch lokal betrieben werden. ⚠ Dabei muss allerdings darauf geachtet werden, dass der Sync für die Server Metrics deaktiviert ist (`DEV_MODE=1`), damit der lokale DEV Server nicht der Live-Instanz die Server Metrics Daten weg klaut.
+
+1. Runtime Dependencies installieren:
+
+```bash
+$ cd server/
+$ npm install
+```
+
+2. Live-Datenbank kopieren:
+
+z.B. per Terminal mittels `scp`:
+
+```bash
+$ mkdir data
+$ scp -p <user>@portantonio.hostbbq.net:/opt/hbbq/monitoring/server/data/data.db ./data/data-live.db
+
+# data/data-live.db als Sicherung behalten, falls was schief geht,
+# data/data.db aus Kopie von data/data-live.db erstellen:
+$ cp data/data-live.db data/data.db
+```
+
+... oder auch einfach manuell mit WinSCP/FileZilla/... (ℹ Das Datenbank-File muss nach `server/data/data.db` - falls Unterordner noch nicht vorhanden, anlegen.)
+
+3. `.env`-File (in `server/`-Root) mit folgendem Inhalt anlegen:
+
+```bash
+LOG_LEVEL=DEBUG
+WEB_PORT=8880
+DATA_DIR=data
+GOOGLE_APPLICATION_CREDENTIALS="google-cloud/firebase-adminsdk.json"
+NOTIFICATION_ICON_URL="https://fcm.hostbbq.net/logo.png"
+DEV_MODE=1
+```
+
+4. Server starten:
+
+```bash
+$ npm start
+```
+
+Danach sollte der Server unter [http://localhost:8880]() zur Verfügung stehen.
+
+5. Angular Dev Server Proxy umleiten:
+
+`ng/proxy-conf.json`:
+
+```diff
+@@ -1,7 +1,7 @@
+ [
+   {
+     "context": ["/api"],
+-    "target": "http://10.8.0.1:8880",
++    "target": "http://localhost:8880",
+     "secure": false,
+     "pathRewrite": { "^/api": "" }
+   }
+```
+
+6. Angular Dev Server wie gewohnt starten:
+
+```bash
+$ cd ng/
+$ (npm install)?
+$ npm start
+```
+
 ### Install
 
 ```bash

+ 4 - 1
common/types/http-check-config.d.ts

@@ -1,3 +1,6 @@
+type CheckDisjunction = Array<string | CheckDisjunction | CheckConjunction>;
+type CheckConjunction = { and: Array<string | CheckDisjunction | CheckConjunction> };
+
 type HttpCheckConfig = {
   id: number;
   serverId?: number;
@@ -9,5 +12,5 @@ type HttpCheckConfig = {
   timeout?: number;
   notify: boolean;
   notifyThreshold: number;
-  checks: string[];
+  checks: CheckDisjunction;
 };

+ 2 - 0
ng/package.json

@@ -31,6 +31,7 @@
     "ng2-charts": "^4.0.1",
     "rxjs": "~7.5.0",
     "tslib": "^2.3.0",
+    "uuid": "^9.0.0",
     "zone.js": "~0.11.4"
   },
   "devDependencies": {
@@ -40,6 +41,7 @@
     "@angular/localize": "^15.0.1",
     "@popperjs/core": "^2.11.6",
     "@types/jasmine": "~4.0.0",
+    "@types/uuid": "^9.0.1",
     "jasmine-core": "~4.3.0",
     "karma": "~6.4.0",
     "karma-chrome-launcher": "~3.1.0",

+ 5 - 1
ng/src/app/app-routing.module.ts

@@ -1,6 +1,7 @@
 import { NgModule } from '@angular/core';
 import { RouterModule, Routes } from '@angular/router';
 import { AdminPanelComponent } from './pages/admin-panel/admin-panel.component';
+import { AdminServiceChecksPageComponent } from './pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component';
 import { HomePageComponent } from './pages/home-page/home-page.component';
 import { ServerDataPageComponent } from './pages/server-data-page/server-data-page.component';
 import { ServiceCheckDetailPageComponent } from './pages/service-check-detail-page/service-check-detail-page.component';
@@ -9,7 +10,10 @@ const routes: Routes = [
   { path: '', pathMatch: 'full', component: HomePageComponent },
   { path: 'srv/:id', component: ServerDataPageComponent },
   { path: 'svc/:serverID/:serviceID', component: ServiceCheckDetailPageComponent },
-  { path: 'admin', component: AdminPanelComponent }
+  { path: 'admin', component: AdminPanelComponent },
+  { path: 'admin/svc', component: AdminServiceChecksPageComponent },
+  { path: 'admin/svc/:serverID', component: AdminServiceChecksPageComponent },
+  { path: 'admin/svc/:serverID/:checkID', component: AdminServiceChecksPageComponent }
 ];
 
 @NgModule({

+ 18 - 1
ng/src/app/app.component.html

@@ -1,7 +1,24 @@
 <app-header></app-header>
 
-<div class="container overflow-hidden position-relative pt-5">
+<div class="container flex-fill overflow-hidden position-relative pt-5">
   <div class="h-100 position-relative pt-3">
     <router-outlet></router-outlet>
   </div>
 </div>
+
+<ng-container>
+  <app-confirm-modal *ngFor="let modal of cmpService.confirmModals" (ref)="modal.ref($event)"></app-confirm-modal>
+</ng-container>
+
+<div class="position-fixed top-0 end-0 m-1" style="z-index: 1200">
+  <ngb-toast
+    *ngFor="let toast of toastService.toasts"
+    [header]="toast.header"
+    [autohide]="true"
+    [delay]="toast.delay || 5000"
+    (hidden)="toastService.remove(toast.id)">
+    <div class="alert alert-{{ toast.alertClass }}">
+      {{ toast.message }}
+    </div>
+  </ngb-toast>
+</div>

+ 3 - 1
ng/src/app/app.component.ts

@@ -1,6 +1,8 @@
 import { Component, OnInit } from '@angular/core';
 
+import { ComponentService } from './services/component.service';
 import { ServerApiService } from './services/server-api.service';
+import { ToastService } from './services/toast.service';
 
 @Component({
   selector: 'app-root',
@@ -8,7 +10,7 @@ import { ServerApiService } from './services/server-api.service';
   styleUrls: ['./app.component.scss']
 })
 export class AppComponent implements OnInit {
-  constructor(private apiService: ServerApiService) {}
+  constructor(private apiService: ServerApiService, public cmpService: ComponentService, public toastService: ToastService) {}
 
   async ngOnInit() {
     try {

+ 18 - 2
ng/src/app/app.module.ts

@@ -3,18 +3,25 @@ import { NgModule } from '@angular/core';
 import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 import { BrowserModule } from '@angular/platform-browser';
 import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
-import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgbAccordionModule, NgbModalModule, NgbNavModule, NgbToastModule } from '@ng-bootstrap/ng-bootstrap';
 import { NgChartsModule } from 'ng2-charts';
 
 import { AppRoutingModule } from './app-routing.module';
 import { AppComponent } from './app.component';
-import { AdminPanelComponent } from './pages/admin-panel/admin-panel.component';
+import { ConfirmModalComponent } from './components/confirm-modal/confirm-modal.component';
 import { HeaderComponent } from './components/header/header.component';
 import { ServerDataChartComponent } from './components/server-data-chart/server-data-chart.component';
 import { ServerMetricsWidgetComponent } from './components/server-metrics-widget/server-metrics-widget.component';
+import { ServiceCheckAdapterComponent } from './components/service-check-editor/service-check-adapter/service-check-adapter.component';
+import { ServiceCheckButtonControlsComponent } from './components/service-check-editor/service-check-button-controls/service-check-button-controls.component';
+import { ServiceCheckConjunctionComponent } from './components/service-check-editor/service-check-conjunction/service-check-conjunction.component';
+import { ServiceCheckDisjunctionComponent } from './components/service-check-editor/service-check-disjunction/service-check-disjunction.component';
+import { ServiceCheckStringComponent } from './components/service-check-editor/service-check-string/service-check-string.component';
 import { ServiceCheckFormComponent } from './components/service-check-form/service-check-form.component';
 import { ServiceChecksWidgetComponent } from './components/service-checks-widget/service-checks-widget.component';
 import { StatusTimelineWidgetComponent } from './components/status-timeline-widget/status-timeline-widget.component';
+import { AdminPanelComponent } from './pages/admin-panel/admin-panel.component';
+import { AdminServiceChecksPageComponent } from './pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component';
 import { HomePageComponent } from './pages/home-page/home-page.component';
 import { ServerDataPageComponent } from './pages/server-data-page/server-data-page.component';
 import { ServiceCheckDetailPageComponent } from './pages/service-check-detail-page/service-check-detail-page.component';
@@ -27,8 +34,10 @@ import { StatusColorPipe } from './pipes/status-color.pipe';
 @NgModule({
   declarations: [
     AdminPanelComponent,
+    AdminServiceChecksPageComponent,
     AppComponent,
     BytePipe,
+    ConfirmModalComponent,
     FaByTypePipe,
     HeaderComponent,
     HomePageComponent,
@@ -36,8 +45,13 @@ import { StatusColorPipe } from './pipes/status-color.pipe';
     ServerDataChartComponent,
     ServerDataPageComponent,
     ServerMetricsWidgetComponent,
+    ServiceCheckAdapterComponent,
+    ServiceCheckButtonControlsComponent,
+    ServiceCheckConjunctionComponent,
     ServiceCheckDetailPageComponent,
+    ServiceCheckDisjunctionComponent,
     ServiceCheckFormComponent,
+    ServiceCheckStringComponent,
     ServiceChecksWidgetComponent,
     StatusColorPipe,
     StatusTimelineWidgetComponent
@@ -49,7 +63,9 @@ import { StatusColorPipe } from './pipes/status-color.pipe';
     FormsModule,
     HttpClientModule,
     NgbAccordionModule,
+    NgbModalModule,
     NgbNavModule,
+    NgbToastModule,
     NgChartsModule,
     ReactiveFormsModule
   ],

+ 13 - 0
ng/src/app/components/confirm-modal/confirm-modal.component.html

@@ -0,0 +1,13 @@
+<ng-template #content let-modal>
+  <div class="modal-header">
+    <h4 class="modal-title">{{ strings.title }}</h4>
+    <button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss(0)"></button>
+  </div>
+  <div class="modal-body">
+    <p>{{ strings.message }}</p>
+  </div>
+  <div class="modal-footer">
+    <button *ngIf="strings.no !== null" type="button" class="btn btn-outline-danger" (click)="modal.close(false)">{{ strings.no }}</button>
+    <button type="button" class="btn btn-outline-primary" (click)="modal.close(true)">{{ strings.yes }}</button>
+  </div>
+</ng-template>

+ 0 - 0
ng/src/app/components/confirm-modal/confirm-modal.component.scss


+ 23 - 0
ng/src/app/components/confirm-modal/confirm-modal.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ConfirmModalComponent } from './confirm-modal.component';
+
+describe('ConfirmModalComponent', () => {
+  let component: ConfirmModalComponent;
+  let fixture: ComponentFixture<ConfirmModalComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ ConfirmModalComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ConfirmModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 63 - 0
ng/src/app/components/confirm-modal/confirm-modal.component.ts

@@ -0,0 +1,63 @@
+import { Component, EventEmitter, Output, TemplateRef, ViewChild } from '@angular/core';
+
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+
+export type ConfirmModalOptions = {
+  buttonTitles?: {
+    yes?: string;
+    no?: string | null;
+  };
+  modalContent?: {
+    title?: string;
+    message?: string;
+  };
+};
+
+@Component({
+  selector: 'app-confirm-modal',
+  templateUrl: './confirm-modal.component.html',
+  styleUrls: ['./confirm-modal.component.scss']
+})
+export class ConfirmModalComponent {
+  @ViewChild('content') content!: TemplateRef<HTMLElement>;
+
+  @Output() ref: EventEmitter<ConfirmModalComponent> = new EventEmitter();
+
+  private defaults: { yes: string; no: string | null; title: string; message: string } = {
+    yes: 'Yes',
+    no: 'No',
+    title: 'Please confirm:',
+    message: '- This could be your message -'
+  };
+
+  public strings = {
+    ...this.defaults
+  };
+
+  constructor(private modalService: NgbModal) {
+    const hdl = setInterval(() => {
+      if (this.ref.observed) {
+        clearInterval(hdl);
+        this.ref.next(this);
+      }
+    }, 100);
+  }
+
+  async open(options?: ConfirmModalOptions): Promise<boolean> {
+    try {
+      this.strings = {
+        ...this.defaults,
+        ...options?.buttonTitles,
+        ...options?.modalContent
+      };
+
+      return await this.modalService.open(this.content).result;
+    } catch (error) {
+      if (typeof error === 'number') {
+        // error is one of "ModalDismissReasons"-Enum
+        return false;
+      }
+      throw error; // something unexpected
+    }
+  }
+}

+ 23 - 0
ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.html

@@ -0,0 +1,23 @@
+<ng-container *ngIf="stringModel !== undefined; else complexModel">
+  <app-service-check-string
+    [model]="stringModel"
+    [sortVisible]="sortVisible"
+    (sort)="sort.emit($event)"
+    (remove)="remove.emit()"></app-service-check-string>
+</ng-container>
+<ng-template #complexModel>
+  <ng-container *ngIf="conjunction; else disjunctModel">
+    <app-service-check-conjunction
+      [model]="conjunction"
+      [sortVisible]="sortVisible"
+      (sort)="sort.emit($event)"
+      (remove)="remove.emit()"></app-service-check-conjunction>
+  </ng-container>
+  <ng-template #disjunctModel>
+    <app-service-check-disjunction
+      [model]="disjunction"
+      [sortVisible]="sortVisible"
+      (sort)="sort.emit($event)"
+      (remove)="remove.emit()"></app-service-check-disjunction>
+  </ng-template>
+</ng-template>

+ 0 - 0
ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.scss


+ 23 - 0
ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ServiceCheckAdapterComponent } from './service-check-adapter.component';
+
+describe('ServiceCheckAdapterComponent', () => {
+  let component: ServiceCheckAdapterComponent;
+  let fixture: ComponentFixture<ServiceCheckAdapterComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ ServiceCheckAdapterComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ServiceCheckAdapterComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 47 - 0
ng/src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component.ts

@@ -0,0 +1,47 @@
+import { Component, Input, ViewChild } from '@angular/core';
+import { ServiceCheckConjunctionComponent } from 'src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component';
+import { ServiceCheckDisjunctionComponent } from 'src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component';
+import { ServiceCheckStringComponent } from 'src/app/components/service-check-editor/service-check-string/service-check-string.component';
+import { ServiceCheckEditorComponent } from 'src/app/components/service-check-editor/service-check-editor.component';
+
+@Component({
+  selector: 'app-service-check-adapter',
+  templateUrl: './service-check-adapter.component.html',
+  styleUrls: ['./service-check-adapter.component.scss']
+})
+export class ServiceCheckAdapterComponent extends ServiceCheckEditorComponent {
+  @ViewChild(ServiceCheckDisjunctionComponent) disCmp?: ServiceCheckDisjunctionComponent;
+  @ViewChild(ServiceCheckConjunctionComponent) conCmp?: ServiceCheckConjunctionComponent;
+  @ViewChild(ServiceCheckStringComponent) expCmp?: ServiceCheckStringComponent;
+  @Input() set model(model: string | CheckDisjunction | CheckConjunction) {
+    if (typeof model === 'string') {
+      this.stringModel = model;
+    } else {
+      if (Object.keys(model).includes('and')) {
+        this.conjunction = model as CheckConjunction;
+      } else {
+        this.disjunction = model as CheckDisjunction;
+      }
+    }
+  }
+
+  public stringModel?: string;
+  public disjunction?: CheckDisjunction;
+  public conjunction?: CheckConjunction;
+
+  public collect(): string | CheckDisjunction | CheckConjunction {
+    if (this.expCmp) return this.expCmp.collect();
+    else if (this.conCmp) return this.conCmp.collect();
+    else if (this.disCmp) return this.disCmp.collect();
+
+    throw new Error('Collect in adapter failed: no type detected');
+  }
+
+  public validate() {
+    if (this.expCmp) return this.expCmp.validate();
+    else if (this.conCmp) return this.conCmp.validate();
+    else if (this.disCmp) return this.disCmp.validate();
+
+    throw new Error('Validate in adapter failed: no type detected');
+  }
+}

+ 6 - 0
ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.html

@@ -0,0 +1,6 @@
+<label class="col-form-label"><fa-icon [icon]="fa.plus" class="pe-2"></fa-icon></label>
+<div class="btn-group">
+  <button type="button" class="btn btn-sm bd-blue-100" (click)="create.emit([])">OR</button>
+  <button type="button" class="btn btn-sm bd-red-200" (click)="create.emit({ and: [] })">AND</button>
+  <button type="button" class="btn btn-sm btn-outline-primary" (click)="create.emit('')"><fa-icon [icon]="fa.terminal"></fa-icon>Expression</button>
+</div>

+ 0 - 0
ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.scss


+ 23 - 0
ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ServiceCheckButtonControlsComponent } from './service-check-button-controls.component';
+
+describe('ServiceCheckButtonControlsComponent', () => {
+  let component: ServiceCheckButtonControlsComponent;
+  let fixture: ComponentFixture<ServiceCheckButtonControlsComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ ServiceCheckButtonControlsComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ServiceCheckButtonControlsComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 13 - 0
ng/src/app/components/service-check-editor/service-check-button-controls/service-check-button-controls.component.ts

@@ -0,0 +1,13 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import { faPlus, faTerminal } from '@fortawesome/free-solid-svg-icons';
+
+@Component({
+  selector: 'app-service-check-button-controls',
+  templateUrl: './service-check-button-controls.component.html',
+  styleUrls: ['./service-check-button-controls.component.scss']
+})
+export class ServiceCheckButtonControlsComponent {
+  @Output() create = new EventEmitter<CheckConjunction | CheckDisjunction | string>();
+
+  public fa = { plus: faPlus, terminal: faTerminal };
+}

+ 32 - 0
ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.html

@@ -0,0 +1,32 @@
+<div
+  *ngIf="model?.and"
+  class="d-grid w-100 check-conjunction"
+  [style.grid-template-rows]="'repeat(' + ((model?.and?.length || 0) + 1) + ', max-content)'">
+  <div
+    class="d-flex flex-column align-items-center h-100 bg-max"
+    style="grid-column: 1 / span 1"
+    [style.grid-row]="'1 / span ' + (model?.and?.length ?? 0) + 1">
+    <fa-icon *ngIf="sortVisible.up" [icon]="fa.up" class="text-muted pointer" (click)="sort.emit('up')"></fa-icon>
+    <div class="flex-fill label-upright-vertical">AND</div>
+    <fa-icon *ngIf="sortVisible.down" [icon]="fa.down" class="text-muted pointer" (click)="sort.emit('down')"></fa-icon>
+  </div>
+
+  <div *ngFor="let check of model?.and; index as i" style="grid-column: 2 / span 1" [style.grid-row]="i + 1 + ' / span 1'" [class.border-top]="i > 0">
+    <app-service-check-adapter
+      [model]="check"
+      [sortVisible]="{ up: i > 0, down: (model?.and?.length ?? 0) > i + 1 }"
+      (sort)="onSubSort(i, $event)"
+      (remove)="onSubRemove(i)"></app-service-check-adapter>
+  </div>
+
+  <div
+    style="grid-column: 2 / span 1"
+    [style.grid-row]="(model?.and?.length ?? 0) + 1 + ' / span 1'"
+    class="d-flex align-items-end ps-2"
+    [class.border-top]="model?.and?.length ?? 0 > 0">
+    <div class="d-flex align-items-baseline w-100">
+      <app-service-check-button-controls class="flex-fill" (create)="model?.and?.push($event)"></app-service-check-button-controls>
+      <fa-icon [icon]="fa.times" class="text-danger ps-2 pe-2 pointer" (click)="remove.emit()"></fa-icon>
+    </div>
+  </div>
+</div>

+ 0 - 0
ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.scss


+ 23 - 0
ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ServiceCheckConjunctionComponent } from './service-check-conjunction.component';
+
+describe('ServiceCheckConjunctionComponent', () => {
+  let component: ServiceCheckConjunctionComponent;
+  let fixture: ComponentFixture<ServiceCheckConjunctionComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ ServiceCheckConjunctionComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ServiceCheckConjunctionComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 48 - 0
ng/src/app/components/service-check-editor/service-check-conjunction/service-check-conjunction.component.ts

@@ -0,0 +1,48 @@
+import { Component, Input, QueryList, ViewChildren } from '@angular/core';
+import { ValidationErrors } from '@angular/forms';
+import { faCaretDown, faCaretUp, faTimes } from '@fortawesome/free-solid-svg-icons';
+
+import { ServiceCheckAdapterComponent } from 'src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component';
+import { ServiceCheckEditorComponent, SortDirection } from 'src/app/components/service-check-editor/service-check-editor.component';
+
+@Component({
+  selector: 'app-service-check-conjunction',
+  templateUrl: './service-check-conjunction.component.html',
+  styleUrls: ['./service-check-conjunction.component.scss']
+})
+export class ServiceCheckConjunctionComponent extends ServiceCheckEditorComponent {
+  @ViewChildren(ServiceCheckAdapterComponent) adapters!: QueryList<ServiceCheckAdapterComponent>;
+  @Input() model?: CheckConjunction;
+
+  public fa = { times: faTimes, down: faCaretDown, up: faCaretUp };
+
+  public collect() {
+    return { and: this.adapters.map(adpt => adpt.collect()) };
+  }
+
+  public validate(): ValidationErrors | null {
+    if (!this.model?.and.length) return { 'empty-and': 'There is an empty AND-expression. Please remove.' };
+    let subValid = {};
+    for (const adp of this.adapters) {
+      subValid = { ...subValid, ...adp.validate() };
+    }
+    return Object.keys(subValid).length > 0 ? subValid : null;
+  }
+
+  public onSubSort(idx: number, dir: SortDirection) {
+    if (!this.model?.and) return;
+
+    const a = this.model.and.splice(idx, 1);
+    if (dir === 'up') {
+      this.model.and.splice(idx - 1, 0, ...a);
+    } else {
+      this.model.and.splice(idx + 1, 0, ...a);
+    }
+  }
+
+  public onSubRemove(idx: number) {
+    if (!this.model?.and) return;
+
+    this.model.and.splice(idx, 1);
+  }
+}

+ 26 - 0
ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.html

@@ -0,0 +1,26 @@
+<div *ngIf="model" class="d-grid w-100 check-disjunction" [style.grid-template-rows]="'repeat(' + (model.length + 1) + ', max-content)'">
+  <div class="d-flex flex-column align-items-center h-100 bg-peak" style="grid-column: 1 / span 1" [style.grid-row]="'1 / span ' + model.length + 1">
+    <fa-icon *ngIf="sortVisible.up" [icon]="fa.up" class="text-muted pointer" (click)="sort.emit('up')"></fa-icon>
+    <div class="flex-fill label-upright-vertical">OR</div>
+    <fa-icon *ngIf="sortVisible.down" [icon]="fa.down" class="text-muted pointer" (click)="sort.emit('down')"></fa-icon>
+  </div>
+
+  <div *ngFor="let check of model; index as i" style="grid-column: 2 / span 1" [style.grid-row]="i + 1 + ' / span 1'" [class.border-top]="i > 0">
+    <app-service-check-adapter
+      [model]="check"
+      [sortVisible]="{ up: i > 0, down: model.length > i + 1 }"
+      (sort)="onSubSort(i, $event)"
+      (remove)="onSubRemove(i)"></app-service-check-adapter>
+  </div>
+
+  <div
+    style="grid-column: 2 / span 1"
+    [style.grid-row]="model.length + 1 + ' / span 1'"
+    class="d-flex align-items-end ps-2"
+    [class.border-top]="model.length > 0">
+    <div class="d-flex align-items-baseline w-100">
+      <app-service-check-button-controls class="flex-fill" (create)="model.push($event)"></app-service-check-button-controls>
+      <fa-icon *ngIf="removable" [icon]="fa.times" class="text-danger ps-2 pe-2 pointer" (click)="remove.emit()"></fa-icon>
+    </div>
+  </div>
+</div>

+ 0 - 0
ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.scss


+ 23 - 0
ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ServiceCheckDisjunctionComponent } from './service-check-disjunction.component';
+
+describe('ServiceCheckDisjunctionComponent', () => {
+  let component: ServiceCheckDisjunctionComponent;
+  let fixture: ComponentFixture<ServiceCheckDisjunctionComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ ServiceCheckDisjunctionComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ServiceCheckDisjunctionComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 49 - 0
ng/src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component.ts

@@ -0,0 +1,49 @@
+import { Component, Input, QueryList, ViewChildren } from '@angular/core';
+import { ValidationErrors } from '@angular/forms';
+import { faCaretDown, faCaretUp, faTimes } from '@fortawesome/free-solid-svg-icons';
+
+import { ServiceCheckAdapterComponent } from 'src/app/components/service-check-editor/service-check-adapter/service-check-adapter.component';
+import { ServiceCheckEditorComponent, SortDirection } from 'src/app/components/service-check-editor/service-check-editor.component';
+
+@Component({
+  selector: 'app-service-check-disjunction',
+  templateUrl: './service-check-disjunction.component.html',
+  styleUrls: ['./service-check-disjunction.component.scss']
+})
+export class ServiceCheckDisjunctionComponent extends ServiceCheckEditorComponent {
+  @ViewChildren(ServiceCheckAdapterComponent) adapters!: QueryList<ServiceCheckAdapterComponent>;
+  @Input() model?: CheckDisjunction;
+  @Input() removable = true;
+
+  public fa = { times: faTimes, down: faCaretDown, up: faCaretUp };
+
+  public collect() {
+    return this.adapters.map(adpt => adpt.collect());
+  }
+
+  public validate(): ValidationErrors | null {
+    if (!this.model?.length) return { 'empty-or': 'There is an empty OR-expression. Please remove.' };
+    let subValid = {};
+    for (const adp of this.adapters) {
+      subValid = { ...subValid, ...adp.validate() };
+    }
+    return Object.keys(subValid).length > 0 ? subValid : null;
+  }
+
+  public onSubSort(idx: number, dir: SortDirection) {
+    if (!this.model) return;
+
+    const a = this.model.splice(idx, 1);
+    if (dir === 'up') {
+      this.model.splice(idx - 1, 0, ...a);
+    } else {
+      this.model.splice(idx + 1, 0, ...a);
+    }
+  }
+
+  public onSubRemove(idx: number) {
+    if (!this.model) return;
+
+    this.model.splice(idx, 1);
+  }
+}

+ 14 - 0
ng/src/app/components/service-check-editor/service-check-editor.component.ts

@@ -0,0 +1,14 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { ValidationErrors } from '@angular/forms';
+
+export type SortDirection = 'up' | 'down';
+
+@Component({ template: '' })
+export abstract class ServiceCheckEditorComponent {
+  @Input() sortVisible: { up: boolean; down: boolean } = { up: true, down: true };
+  @Output() sort: EventEmitter<SortDirection> = new EventEmitter();
+  @Output() remove: EventEmitter<void> = new EventEmitter();
+
+  abstract collect(): string | CheckDisjunction | CheckConjunction;
+  abstract validate(): ValidationErrors | null;
+}

+ 11 - 0
ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.html

@@ -0,0 +1,11 @@
+<div class="input-group">
+  <div class="input-group-text text-secondary justify-content-center flex-column border-0 border-end p-0">
+    <fa-icon *ngIf="sortVisible.up" [icon]="fa.up" class="text-muted pointer" (click)="sort.emit('up')"></fa-icon>
+    <span [class.flex-fill]="sortVisible.up || sortVisible.down">
+      <fa-icon [icon]="fa.terminal" *ngIf="!sortVisible.up && !sortVisible.down"></fa-icon>
+    </span>
+    <fa-icon *ngIf="sortVisible.down" [icon]="fa.down" class="text-muted pointer" (click)="sort.emit('down')"></fa-icon>
+  </div>
+  <input type="text" class="border-0 form-control flex-fill" [(ngModel)]="model" />
+  <fa-icon [icon]="fa.times" class="text-danger align-self-center ps-2 pe-2 pointer" (click)="remove.emit()"></fa-icon>
+</div>

+ 0 - 0
ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.scss


+ 23 - 0
ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ServiceCheckStringComponent } from './service-check-string.component';
+
+describe('ServiceCheckStringComponent', () => {
+  let component: ServiceCheckStringComponent;
+  let fixture: ComponentFixture<ServiceCheckStringComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ ServiceCheckStringComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(ServiceCheckStringComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 25 - 0
ng/src/app/components/service-check-editor/service-check-string/service-check-string.component.ts

@@ -0,0 +1,25 @@
+import { Component, Input } from '@angular/core';
+import { ValidationErrors } from '@angular/forms';
+import { faCaretDown, faCaretUp, faTerminal, faTimes } from '@fortawesome/free-solid-svg-icons';
+
+import { ServiceCheckEditorComponent } from '../service-check-editor.component';
+
+@Component({
+  selector: 'app-service-check-string',
+  templateUrl: './service-check-string.component.html',
+  styleUrls: ['./service-check-string.component.scss']
+})
+export class ServiceCheckStringComponent extends ServiceCheckEditorComponent {
+  @Input() model?: string;
+
+  public fa = { down: faCaretDown, terminal: faTerminal, times: faTimes, up: faCaretUp };
+
+  public collect() {
+    return this.model ?? '';
+  }
+
+  public validate(): ValidationErrors | null {
+    if (!this.model?.trim().length) return { 'empty-expr': 'There is an empty regular expression. Please remove.' };
+    return null;
+  }
+}

+ 114 - 46
ng/src/app/components/service-check-form/service-check-form.component.html

@@ -1,61 +1,129 @@
-<div [formGroup]="serviceCheckForm" class="row form-row-service-check position-relative">
-  <div class="col-7">
-    <div class="row mb-2">
-      <label [for]="'Title_' + serviceCheck.id" class="col-sm-3 col-form-label">Title</label>
-      <div class="col-sm-9">
-        <input type="text" formControlName="title" class="form-control" [id]="'Title_' + serviceCheck.id" />
-      </div>
-    </div>
-    <div class="row mb-2">
-      <label class="col-sm-3 form-check-label" [for]="'Active_' + serviceCheck.id">Active</label>
-      <div class="col-sm-9">
-        <input class="form-check-input" formControlName="active" type="checkbox" value="" [id]="'Active_' + serviceCheck.id" />
+<div [formGroup]="serviceCheckForm">
+  <div class="row">
+    <div class="col-8">
+      <div class="row mb-2">
+        <label class="col-3 col-form-label">Title</label>
+        <div class="col-9">
+          <input type="text" formControlName="title" class="form-control" />
+        </div>
       </div>
     </div>
-    <div class="row mb-2">
-      <label [for]="'Interval_' + serviceCheck.id" class="col-sm-3 col-form-label">Interval (secs)</label>
-      <div class="col-sm-9">
-        <input type="number" step="10" formControlName="interval" class="form-control" [id]="'Interval_' + serviceCheck.id" />
+    <div class="col-4 h-100 overflow-auto">
+      <div class="row mb-2 mt-2">
+        <label class="form-check-label text-end">
+          <input class="form-check-input" formControlName="active" type="checkbox" value="" />
+          Active
+        </label>
       </div>
     </div>
-    <div class="row mb-2">
-      <label [for]="'Timeout_' + serviceCheck.id" class="col-sm-3 col-form-label">Timeout (ms)</label>
-      <div class="col-sm-9">
-        <input type="number" step="100" formControlName="timeout" class="form-control" [id]="'Timeout_' + serviceCheck.id" />
+  </div>
+  <div class="row border-top">
+    <div class="col-12">
+      <div class="row mb-2 mt-2">
+        <label class="col-2 col-form-label">Url</label>
+        <div class="col-10">
+          <input type="text" formControlName="url" class="form-control" />
+        </div>
       </div>
     </div>
-    <div class="row mb-2">
-      <label [for]="'Url_' + serviceCheck.id" class="col-sm-3 col-form-label">Url</label>
-      <div class="col-sm-9">
-        <input type="text" formControlName="url" class="form-control" [id]="'Url_' + serviceCheck.id" />
+  </div>
+  <div class="row border-top">
+    <label class="col-lg-2 col-form-label mt-2">Settings</label>
+    <div class="col-lg-10">
+      <div class="row">
+        <div class="col-lg-6">
+          <div class="row mb-2 mt-2">
+            <label class="col-2 col-lg-5 col-form-label">Interval:</label>
+            <div class="col-10 col-lg-7">
+              <div class="input-group">
+                <input type="number" step="10" formControlName="interval" class="form-control" />
+                <span class="input-group-text">s</span>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="col-lg-6">
+          <div class="row mb-2 mt-2">
+            <label class="col-2 col-lg-5 col-form-label">Timeout:</label>
+            <div class="col-10 col-lg-7">
+              <div class="input-group">
+                <input type="number" step="100" formControlName="timeout" class="form-control" />
+                <span class="input-group-text">ms</span>
+              </div>
+            </div>
+          </div>
+        </div>
       </div>
     </div>
-    <div class="row mb-2">
-      <label [for]="'Notify_' + serviceCheck.id" class="col-sm-3 col-form-label">Notify</label>
-      <div class="col-sm-9">
-        <input type="checkbox" formControlName="notify" class="form-check-input" [id]="'Notify_' + serviceCheck.id" />
+  </div>
+  <div class="row border-top">
+    <label class="col-lg-2 col-form-label mt-2">Notification</label>
+    <div class="col-lg-10">
+      <div class="row mb-2 mt-2">
+        <label class="col-sm-3 col-form-label">
+          <input type="checkbox" formControlName="notify" class="form-check-input" />
+          Notify
+        </label>
+
+        <div class="col-sm-9">
+          <div class="row">
+            <label class="col-4 col-form-label text-end">Threshold:</label>
+            <div class="col-8">
+              <input type="number" formControlName="notifyThreshold" class="form-control" />
+            </div>
+          </div>
+        </div>
       </div>
     </div>
-    <div class="row mb-2">
-      <label [for]="'NotifyThreshold_' + serviceCheck.id" class="col-sm-3 col-form-label">Notify Threshold</label>
-      <div class="col-sm-9">
-        <input type="number" formControlName="notifyThreshold" class="form-control" [id]="'NotifyThreshold_' + serviceCheck.id" />
+  </div>
+  <div class="row border-top">
+    <div class="col-12">
+      <label class="mt-2">Check</label>
+      <div class="mt-2 position-relative border p-0">
+        <app-service-check-disjunction
+          [model]="serviceCheck.checks"
+          [sortVisible]="{ up: false, down: false }"
+          [removable]="false"></app-service-check-disjunction>
       </div>
     </div>
   </div>
-  <div class="col-5 h-100 overflow-auto">
-    <ul formArrayName="checks" class="list-group">
-      <li class="list-group-item">
-        <div class="btn w-100" title="Add new search pattern" (click)="addPattern()"><fa-icon [icon]="fa.plus"></fa-icon></div>
-      </li>
-      <li *ngFor="let checkEntry of checks.controls; let i = index" class="list-group-item">
-        <div class="flex-row d-flex">
-          <input #checkInput type="text" class="flex-grow-1 flex-shrink-1 form-control-plaintext" [formControlName]="i" />
-          <div class="btn flex-grow-0 flex-shrink-0" title="Remove search pattern" (click)="removePattern(i)">
-            <fa-icon [icon]="fa.delete"></fa-icon>
-          </div>
+</div>
+
+<ng-template #testModalContent let-modal>
+  <div class="modal-header">
+    <h4 class="modal-title">Test run of "{{ testRun?.config?.title }}"</h4>
+    <button type="button" class="btn-close" aria-label="Close" (click)="modal.dismiss(0)"></button>
+  </div>
+  <div class="modal-body">
+    <ng-container *ngIf="!testRun?.loading; else testRunLoading">
+      <ng-container *ngIf="testRun?.result">
+        <table class="table">
+          <colgroup>
+            <col width="180" />
+            <col width="*" />
+          </colgroup>
+          <tbody>
+            <tr [class]="testRun?.result | statusColor">
+              <th scope="row" [rowSpan]="testRun?.result?.data?.length">{{ testRun?.result?.time | date : 'yyyy-MM-dd HH:mm:ss' }}</th>
+              <td>{{ testRun?.result?.data?.[0]?.message }}</td>
+            </tr>
+            <tr *ngFor="let msg of testRun?.result?.data | slice : 1" [class]="testRun?.result | statusColor">
+              <td>{{ msg.message }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </ng-container>
+      <ng-container *ngIf="testRun?.error">
+        <div class="alert alert-danger">
+          <pre>{{ testRun?.error | json }}</pre>
         </div>
-      </li>
-    </ul>
+      </ng-container>
+    </ng-container>
+    <ng-template #testRunLoading>
+      <app-status-timeline-widget></app-status-timeline-widget>
+    </ng-template>
   </div>
-</div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-outline-primary" (click)="modal.close(true)">Close</button>
+  </div>
+</ng-template>

+ 0 - 4
ng/src/app/components/service-check-form/service-check-form.component.scss

@@ -1,4 +0,0 @@
-.form-row-service-check {
-  max-height: 320px;
-  height: 320px;
-}

+ 54 - 44
ng/src/app/components/service-check-form/service-check-form.component.ts

@@ -1,75 +1,85 @@
-import { Component, ElementRef, Input, OnInit, QueryList, ViewChildren } from '@angular/core';
-import { FormControl, FormGroup, FormArray, FormBuilder } from '@angular/forms';
+import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { FormGroup, FormBuilder } from '@angular/forms';
 import { faMinusSquare, faPlusSquare } from '@fortawesome/free-solid-svg-icons';
-import { ServiceApiService } from '../../services/service-api.service';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { ServiceCheckData } from '../../../../../common/lib/http-check-data.module';
 import { deepCopy } from '../../../../../common/util/object-utils';
 
+import { ServiceCheckDisjunctionComponent } from 'src/app/components/service-check-editor/service-check-disjunction/service-check-disjunction.component';
+import { ServiceApiService } from 'src/app/services/service-api.service';
+
 @Component({
   selector: 'app-service-check-form',
   templateUrl: './service-check-form.component.html',
   styleUrls: ['./service-check-form.component.scss']
 })
 export class ServiceCheckFormComponent implements OnInit {
-  @ViewChildren('checkInput')
-  set checkRows(childs: QueryList<ElementRef<HTMLInputElement>>) {
-    if (childs?.length && childs.get(0)?.nativeElement.value === '') {
-      //new added children via addPattern without value gets focus
-      childs.get(0)?.nativeElement.focus();
-    }
-  }
+  @ViewChild(ServiceCheckDisjunctionComponent) checkEditor!: ServiceCheckDisjunctionComponent;
+  @ViewChild('testModalContent') testModalContent!: TemplateRef<HTMLElement>;
 
-  @Input()
-  serviceCheck!: HttpCheckConfig;
+  @Input() serviceCheck!: HttpCheckConfig;
   public fa = { plus: faPlusSquare, delete: faMinusSquare };
 
   serviceCheckForm: FormGroup = this.formBuilder.group({
-    id: -1, //constant for defaultValues
-    title: '', //constant for defaultValues
-    active: undefined, //constant for defaultValues
-    url: '', //constant for defaultValues
-    interval: 10, //constant for defaultValues
-    timeout: 10, //constant for defaultValuess
-    checks: this.formBuilder.array([]),
+    id: -1,
+    title: '',
+    active: true,
+    url: '',
+    interval: 300,
+    timeout: 10000,
     notify: true,
     notifyThreshold: 3
   });
 
-  constructor(private formBuilder: FormBuilder, private serviceApi: ServiceApiService) {}
+  testRun?: { config: HttpCheckConfig; loading: boolean; error?: any; result?: ServiceCheckData };
 
-  get checks(): FormArray {
-    return this.serviceCheckForm.controls['checks'] as FormArray;
-  }
+  constructor(private formBuilder: FormBuilder, private modalService: NgbModal, private serviceApi: ServiceApiService) {}
 
   ngOnInit(): void {
-    this.patchForm(this.serviceCheck);
+    this.serviceCheckForm.patchValue(this.serviceCheck);
   }
 
-  private patchForm(serviceCheck: HttpCheckConfig) {
-    this.checks.controls = [];
-    serviceCheck.checks.reverse().forEach(e => {
-      this.checks.controls.push(new FormControl(e));
-    });
-    this.serviceCheckForm.patchValue(serviceCheck);
-  }
+  async save() {
+    this.serviceCheckForm.updateValueAndValidity();
+    const copy = deepCopy(this.serviceCheckForm.value as HttpCheckConfig);
 
-  addPattern() {
-    this.checks.controls.unshift(new FormControl(''));
-    //focus set via viewChildren setter, here is too early
-  }
+    const invalid = this.checkEditor.validate();
+    if (null !== invalid) {
+      throw { type: 'validation', errors: invalid };
+    }
+
+    copy.checks = this.checkEditor.collect();
 
-  removePattern(index: number) {
-    this.checks.controls.splice(index, 1);
+    const savedCheck = await this.serviceApi.saveServiceCheck(this.serviceCheck.serverId as number, copy);
+    this.serviceCheckForm.patchValue(savedCheck);
+    this.serviceCheck = savedCheck;
+
+    return savedCheck;
   }
 
-  async save() {
+  async test() {
     try {
-      this.serviceCheckForm.updateValueAndValidity();
       const copy = deepCopy(this.serviceCheckForm.value as HttpCheckConfig);
-      copy.checks = this.checks.controls.map(e => e.value).reverse();
-      const savedCheck = await this.serviceApi.saveServiceCheck(this.serviceCheck.serverId as number, copy);
-      this.patchForm(savedCheck);
-    } catch (error: any) {
-      console.error(error);
+      copy.checks = this.checkEditor.collect();
+
+      // open modal with loading indicator
+      this.testRun = { config: copy, loading: true };
+      this.modalService.open(this.testModalContent);
+
+      const log = await this.serviceApi.testServiceCheck(copy);
+
+      const checkResult: ServiceCheckData = {
+        time: log[0].time,
+        data: log
+      };
+
+      // show result in modal
+      this.testRun.result = checkResult;
+    } catch (err) {
+      if (this.testRun) this.testRun.error = err;
+      console.error(err);
     }
+    if (this.testRun) this.testRun.loading = false;
   }
 }

+ 4 - 24
ng/src/app/pages/admin-panel/admin-panel.component.html

@@ -1,25 +1,5 @@
-<div class="d-flex">
-  <ul ngbNav #nav="ngbNav" class="nav-pills" orientation="vertical">
-    <li *ngFor="let serverConfig of serverConfigs" ngbNavItem>
-      <a ngbNavLink (click)="fetchServiceChecks(serverConfig.id)">{{ serverConfig.title }}</a>
-      <ng-template ngbNavContent>
-        <button class="btn" (click)="addServiceCheck(serverConfig.id)">Add</button>
-        <ngb-accordion #acc="ngbAccordion">
-          <ngb-panel *ngFor="let serviceCheck of serviceChecks; let i = index">
-            <ng-template ngbPanelHeader let-opened="opened">
-              <button class="accordion-button" ngbPanelToggle [class.collapsed]="!opened">
-                <p class="flex-fill m-0">{{ serviceCheck.title }}</p>
+<p>Redirecting ...</p>
 
-                <fa-icon class="me-1" (click)="saveServiceCheck(i, $event)" *ngIf="opened" [icon]="fa.save"></fa-icon>
-              </button>
-            </ng-template>
-            <ng-template ngbPanelContent>
-              <app-service-check-form [serviceCheck]="serviceCheck"></app-service-check-form>
-            </ng-template>
-          </ngb-panel>
-        </ngb-accordion>
-      </ng-template>
-    </li>
-  </ul>
-  <div [ngbNavOutlet]="nav" class="flex-fill"></div>
-</div>
+<!-- This Page can be filled with a Admin Dashboard and Links to subsequent admin pages. -->
+<!-- As there are not any more subsequent pages than the "Service Checks" page, this     -->
+<!-- page is redirecting to that page by default. -->

+ 6 - 31
ng/src/app/pages/admin-panel/admin-panel.component.ts

@@ -1,41 +1,16 @@
-import { Component, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
-import { faSave } from '@fortawesome/free-solid-svg-icons';
-import { ServiceApiService } from '../../services/service-api.service';
-import { ServerApiService } from '../../services/server-api.service';
-import { ServiceCheckFormComponent } from '../../components/service-check-form/service-check-form.component';
+import { Component, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
 
 @Component({
   selector: 'app-admin-panel',
   templateUrl: './admin-panel.component.html',
   styleUrls: ['./admin-panel.component.scss'],
-  host: { class: 'd-flex flex-column h-100 overflow-auto' }
+  host: { class: 'd-flex flex-column h-100' }
 })
 export class AdminPanelComponent implements OnInit {
-  @ViewChildren(ServiceCheckFormComponent) formChilds!: QueryList<ServiceCheckFormComponent>;
-  public serverConfigs: ServerConfig[] = [];
-  public serviceChecks: HttpCheckConfig[] = [];
-  public fa = { save: faSave };
+  constructor(private router: Router) {}
 
-  constructor(private serviceApi: ServiceApiService, apiService: ServerApiService) {
-    apiService.serverConfigs$.subscribe(data => (this.serverConfigs = data));
-  }
-
-  ngOnInit(): void {}
-
-  async fetchServiceChecks(serverId: number) {
-    try {
-      this.serviceChecks = await this.serviceApi.loadServiceChecks(serverId);
-    } catch (error: any) {
-      console.error(error);
-    }
-  }
-
-  saveServiceCheck(index: number, event: Event) {
-    event.stopPropagation();
-    this.formChilds.get(index)?.save();
-  }
-
-  addServiceCheck(serverId: number) {
-    this.serviceChecks.unshift({ serverId, checks: [] as string[] } as HttpCheckConfig);
+  ngOnInit(): void {
+    this.router.navigateByUrl('/admin/svc');
   }
 }

+ 64 - 0
ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.html

@@ -0,0 +1,64 @@
+<h3>Service Checks</h3>
+
+<div *ngIf="serverConfigs?.length; else loadingServerConfs" class="border d-flex flex-fill overflow-hidden">
+  <ul ngbNav #nav="ngbNav" class="border-end nav-pills h-100" style="width: 250px" orientation="vertical" [(activeId)]="activeId">
+    <li *ngFor="let serverConfig of serverConfigs; index as s" [ngbNavItem]="s" class="bg-light text-primary">
+      <a ngbNavLink [routerLink]="'/admin/svc/' + serverConfig.id" class="d-flex btn btn-toolbar">
+        <fa-icon class="pe-2" [icon]="fa.server"></fa-icon>
+        <span class="flex-fill text-start">{{ serverConfig.title }}</span>
+        <fa-icon class="ps-2" [icon]="fa.angleRight"></fa-icon>
+      </a>
+      <ng-template ngbNavContent>
+        <ng-container *ngIf="!loadingServiceChecks; else loadingServerConfs">
+          <div class="d-flex flex-column">
+            <div class="btn-group d-block">
+              <button class="btn btn-primary float-end m-1" [disabled]="params?.checkID === -1" (click)="addServiceCheck(serverConfig.id)">
+                <fa-icon class="pe-2" [icon]="fa.plus"></fa-icon>Add Service Check
+              </button>
+            </div>
+            <ngb-accordion
+              #acc="ngbAccordion"
+              [closeOthers]="true"
+              className="flex-fill"
+              [activeIds]="params?.activeIds ?? []"
+              (panelChange)="onAccordeonChange($event)">
+              <ngb-panel *ngFor="let serviceCheck of serviceChecks" [id]="PANEL_ID_PFX + serviceCheck.id">
+                <ng-template ngbPanelHeader let-opened="opened">
+                  <button class="accordion-button" ngbPanelToggle [class.collapsed]="!opened" [ngClass]="{ 'bg-primary text-white': opened }">
+                    <p class="flex-fill m-0">{{ serviceCheck.title }}</p>
+
+                    <fa-icon
+                      class="btn btn-sm btn-outline-light me-4"
+                      title="Test run"
+                      (click)="testServiceCheck($event)"
+                      *ngIf="opened"
+                      [icon]="fa.play"></fa-icon>
+                    <fa-icon
+                      class="btn btn-sm btn-outline-light me-4"
+                      title="Save configuration"
+                      (click)="saveServiceCheck($event)"
+                      *ngIf="opened"
+                      [icon]="fa.save"></fa-icon>
+                    <fa-icon
+                      class="btn btn-sm btn-outline-danger me-4"
+                      title="Delete this Service Check"
+                      (click)="removeServiceCheck($event, serviceCheck.id)"
+                      *ngIf="opened"
+                      [icon]="fa.trash"></fa-icon>
+                  </button>
+                </ng-template>
+                <ng-template ngbPanelContent>
+                  <app-service-check-form [serviceCheck]="serviceCheck"></app-service-check-form>
+                </ng-template>
+              </ngb-panel>
+            </ngb-accordion>
+          </div>
+        </ng-container>
+      </ng-template>
+    </li>
+  </ul>
+  <div [ngbNavOutlet]="nav" class="flex-fill h-100 overflow-auto"></div>
+</div>
+<ng-template #loadingServerConfs>
+  <app-status-timeline-widget></app-status-timeline-widget>
+</ng-template>

+ 0 - 0
ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.scss


+ 23 - 0
ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminServiceChecksPageComponent } from './admin-service-checks-page.component';
+
+describe('AdminServiceChecksPageComponent', () => {
+  let component: AdminServiceChecksPageComponent;
+  let fixture: ComponentFixture<AdminServiceChecksPageComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ AdminServiceChecksPageComponent ]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(AdminServiceChecksPageComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 186 - 0
ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.ts

@@ -0,0 +1,186 @@
+import { Component, ViewChild } from '@angular/core';
+import { ValidationErrors } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { faAngleRight, faPlayCircle, faPlus, faSave, faServer, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
+import { NgbAccordion, NgbPanelChangeEvent } from '@ng-bootstrap/ng-bootstrap';
+
+import { ServiceCheckFormComponent } from 'src/app/components/service-check-form/service-check-form.component';
+import { ComponentService } from 'src/app/services/component.service';
+import { ServiceApiService } from 'src/app/services/service-api.service';
+import { ServerApiService } from 'src/app/services/server-api.service';
+import { ToastService } from 'src/app/services/toast.service';
+
+@Component({
+  selector: 'app-admin-service-checks-page',
+  templateUrl: './admin-service-checks-page.component.html',
+  styleUrls: ['./admin-service-checks-page.component.scss'],
+  host: { class: 'd-flex flex-column h-100' }
+})
+export class AdminServiceChecksPageComponent {
+  public PANEL_ID_PFX = 'acc-svc-checks-';
+  public PANEL_ID_REG = /^acc-svc-checks-(-?\d+)$/;
+
+  @ViewChild(ServiceCheckFormComponent) formRef!: ServiceCheckFormComponent;
+  @ViewChild('acc') accordeonRef!: NgbAccordion;
+  public serverConfigs?: ServerConfig[];
+  public serviceChecks: HttpCheckConfig[] = [];
+  public loadingServiceChecks = false;
+  public fa = { save: faSave, server: faServer, angleRight: faAngleRight, plus: faPlus, trash: faTrashAlt, play: faPlayCircle };
+
+  public activeId = 0;
+  public params?: { serverID?: number; checkID?: number; activeIds?: string[] };
+
+  constructor(
+    apiService: ServerApiService,
+    private cmpService: ComponentService,
+    route: ActivatedRoute,
+    private router: Router,
+    private serviceApi: ServiceApiService,
+    private toastService: ToastService
+  ) {
+    apiService.serverConfigs$.subscribe(data => {
+      this.serverConfigs = data;
+      this.syncInit();
+    });
+    route.params.subscribe({
+      next: params => {
+        if (!params) return;
+        this.params = {
+          serverID: params['serverID'] ? Number(params['serverID']) : undefined,
+          checkID: params['checkID'] ? Number(params['checkID']) : undefined
+        };
+        this.syncInit();
+      }
+    });
+  }
+
+  ngOnInit(): void {}
+
+  async syncInit() {
+    if (!this.params) return;
+    if (!this.serverConfigs) return;
+
+    if (this.serverConfigs.length > 0) {
+      if (!this.params.serverID) {
+        // redirect to first server's id
+        this.router.navigateByUrl(`/admin/svc/${this.serverConfigs[0].id}`);
+        return;
+      }
+
+      // set selected nav item and load service checks
+      this.activeId = this.serverConfigs.findIndex(c => c.id === this.params?.serverID);
+      await this.fetchServiceChecks(this.params.serverID);
+
+      if (this.params.checkID) {
+        // open accordeon panel
+        if (this.params.checkID === -1) {
+          this.addServiceCheck(this.params.serverID);
+        } else {
+          const activePanelId = `${this.PANEL_ID_PFX}${this.params.checkID}`;
+          this.params.activeIds = [activePanelId];
+          setTimeout(() => this.scrollIntoView(activePanelId));
+        }
+      }
+    }
+  }
+
+  async fetchServiceChecks(serverId: number) {
+    this.loadingServiceChecks = true;
+    try {
+      this.serviceChecks = await this.serviceApi.loadServiceChecks(serverId);
+    } catch (error: any) {
+      console.error(error);
+    }
+    this.loadingServiceChecks = false;
+  }
+
+  private scrollIntoView(panelId: string) {
+    const contentPanel = document.getElementById(panelId);
+    let parent: HTMLElement | null = null;
+    while ((parent = contentPanel?.parentElement ?? null)) {
+      if (parent.classList.contains('accordion-item')) break;
+    }
+    parent?.scrollIntoView();
+  }
+
+  onAccordeonChange(event: NgbPanelChangeEvent) {
+    if (event.nextState) {
+      const checkId = this.PANEL_ID_REG.exec(event.panelId)?.[1];
+      if (this.params) this.params.checkID = Number(checkId);
+      history.pushState(null, '', `/admin/svc/${this.params?.serverID}/${checkId}`);
+    } else {
+      if (this.params) this.params.checkID = undefined;
+      history.pushState(null, '', `/admin/svc/${this.params?.serverID}`);
+    }
+  }
+
+  async saveServiceCheck(event: Event) {
+    event.stopPropagation();
+    try {
+      if (!this.params || !this.params.serverID) return;
+      const savedCheck = await this.formRef?.save();
+      const panelId = `${this.PANEL_ID_PFX}${savedCheck.id}`;
+
+      if (this.params.checkID === -1) {
+        await this.fetchServiceChecks(this.params.serverID);
+        this.params.activeIds = [panelId];
+        this.onAccordeonChange({ nextState: true, panelId, preventDefault: () => {} });
+      } else {
+        const idx = this.serviceChecks.findIndex(c => savedCheck.id === c.id);
+        if (idx >= 0) this.serviceChecks[idx] = savedCheck;
+      }
+    } catch (error: any) {
+      if (error.type === 'validation') {
+        const invalid: ValidationErrors = error.errors;
+        Object.entries(invalid).forEach(entry => {
+          this.toastService.error(entry[1], entry[0], 180000);
+        });
+      }
+      console.error(error);
+    }
+  }
+
+  addServiceCheck(serverId: number) {
+    const panelId = `${this.PANEL_ID_PFX}-1`;
+    this.serviceChecks.unshift({ id: -1, serverId, checks: [] as string[] } as HttpCheckConfig);
+    if (this.params) {
+      this.params.activeIds = [panelId];
+    }
+    this.onAccordeonChange({ nextState: true, panelId, preventDefault: () => {} });
+  }
+
+  async removeServiceCheck(event: Event, checkId: number) {
+    event.stopPropagation();
+    try {
+      if (!this.params?.serverID) return;
+
+      if (checkId === -1) {
+        const decision = await this.cmpService.openConfirmModal({
+          modalContent: { message: `Really delete unsaved service check?` }
+        });
+        if (decision) this.serviceChecks.shift();
+      } else {
+        const decision = await this.cmpService.openConfirmModal({
+          modalContent: { message: `Really delete service check #${checkId}?` }
+        });
+
+        if (!decision) return;
+
+        await this.serviceApi.deleteServiceCheck(this.params.serverID, checkId);
+
+        const idx = this.serviceChecks.findIndex(c => c.id === checkId);
+        if (idx >= 0) this.serviceChecks.splice(idx, 1);
+      }
+
+      this.params.activeIds = [];
+      this.onAccordeonChange({ nextState: false, panelId: '', preventDefault: () => {} });
+    } catch (error) {
+      console.error(error);
+    }
+  }
+
+  public async testServiceCheck(event: Event) {
+    event.stopPropagation();
+    await this.formRef?.test();
+  }
+}

+ 1 - 1
ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.html

@@ -37,7 +37,7 @@
               <th scope="row" [rowSpan]="entry.data.length">{{ entry.time | date : 'yyyy-MM-dd HH:mm:ss' }}</th>
               <td>{{ entry.data[0].message }}</td>
             </tr>
-            <tr *ngFor="let msg of entry.data | slice : 1">
+            <tr *ngFor="let msg of entry.data | slice : 1" [class]="entry | statusColor">
               <td>{{ msg.message }}</td>
             </tr>
           </ng-container>

+ 16 - 0
ng/src/app/services/component.service.spec.ts

@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { ComponentService } from './component.service';
+
+describe('ComponentService', () => {
+  let service: ComponentService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.inject(ComponentService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+});

+ 37 - 0
ng/src/app/services/component.service.ts

@@ -0,0 +1,37 @@
+import { Injectable } from '@angular/core';
+import { v4 as uuid } from 'uuid';
+
+import { ConfirmModalComponent, ConfirmModalOptions } from 'src/app/components/confirm-modal/confirm-modal.component';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class ComponentService {
+  public confirmModals: { ref: (cmp: ConfirmModalComponent) => void; hdl: string }[] = [];
+
+  constructor() {}
+
+  async openConfirmModal(options?: ConfirmModalOptions) {
+    return new Promise<boolean>((resolve, reject) => {
+      try {
+        const modalModel = {
+          hdl: uuid(),
+          ref: async (cmp: ConfirmModalComponent) => {
+            try {
+              const result = await cmp.open(options);
+              resolve(result);
+            } catch (error) {
+              reject(error);
+            }
+
+            const idx = this.confirmModals.findIndex(c => c.hdl === modalModel.hdl);
+            if (idx >= 0) this.confirmModals.splice(idx, 1);
+          }
+        };
+        this.confirmModals.push(modalModel);
+      } catch (error) {
+        reject(error);
+      }
+    });
+  }
+}

+ 13 - 1
ng/src/app/services/service-api.service.ts

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
 import { firstValueFrom, map } from 'rxjs';
 
-import { ServiceCheckData } from '../../../../common/lib/http-check-data.module';
+import { HttpCheckData, ServiceCheckData } from '../../../../common/lib/http-check-data.module';
 
 import { environment } from 'src/environments/environment';
 
@@ -24,6 +24,10 @@ export class ServiceApiService {
     return firstValueFrom(this.http.put<HttpCheckConfig>(`${environment.apiBaseUrl}services/${serverId}`, checkConfig));
   }
 
+  public deleteServiceCheck(serverID: number, serviceID: number): Promise<boolean> {
+    return firstValueFrom(this.http.delete<any>(`${environment.apiBaseUrl}services/${serverID}/${serviceID}`).pipe(map(() => true)));
+  }
+
   public queryServiceData(serverID: number, serviceID: number, start: Date, end: Date) {
     return firstValueFrom(
       this.http
@@ -43,4 +47,12 @@ export class ServiceApiService {
         .pipe(map(resp => resp.data.map(data => ({ ...data, time: new Date(data.time) })) as ServiceCheckData[]))
     );
   }
+
+  public testServiceCheck(checkConfig: HttpCheckConfig): Promise<HttpCheckData[]> {
+    return firstValueFrom(
+      this.http
+        .post<HttpCheckData[]>(`${environment.apiBaseUrl}services/test`, checkConfig)
+        .pipe(map(resp => resp.map(data => ({ ...data, time: new Date(data.time) }))))
+    );
+  }
 }

+ 16 - 0
ng/src/app/services/toast.service.spec.ts

@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { ToastService } from './toast.service';
+
+describe('ToastService', () => {
+  let service: ToastService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.inject(ToastService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+});

+ 53 - 0
ng/src/app/services/toast.service.ts

@@ -0,0 +1,53 @@
+import { Injectable } from '@angular/core';
+import { v4 as uuid } from 'uuid';
+
+interface ToastOptions {
+  id: string;
+  header: string;
+  message: string;
+  alertClass: 'danger' | 'warning' | 'info';
+  delay?: number;
+}
+
+@Injectable({
+  providedIn: 'root'
+})
+export class ToastService {
+  public toasts: ToastOptions[] = [];
+
+  constructor() {}
+
+  public error(message: string, header: string, delay?: number) {
+    this.toasts.push({
+      id: uuid(),
+      message,
+      header,
+      alertClass: 'danger',
+      delay
+    });
+  }
+
+  public warning(message: string, header: string, delay?: number) {
+    this.toasts.push({
+      id: uuid(),
+      message,
+      header,
+      alertClass: 'warning',
+      delay
+    });
+  }
+  public info(message: string, header: string, delay?: number) {
+    this.toasts.push({
+      id: uuid(),
+      message,
+      header,
+      alertClass: 'info',
+      delay
+    });
+  }
+
+  public remove(id: string) {
+    const idx = this.toasts.findIndex(t => t.id === id);
+    if (idx >= 0) this.toasts.splice(idx, 1);
+  }
+}

BIN
ng/src/favicon.ico


+ 20 - 1
ng/src/styles.scss

@@ -17,7 +17,7 @@
 }
 
 .bg-progress {
-  background-color: #e9ecef !important;
+  background-color: $progress-bg !important;
 }
 
 .text-avg {
@@ -31,3 +31,22 @@
 .text-max {
   color: #941320 !important;
 }
+
+.check-disjunction,
+.check-conjunction {
+  &.d-grid {
+    grid-template-columns: 30px 1fr;
+
+    .label-upright-vertical {
+      writing-mode: vertical-lr;
+      text-orientation: upright;
+      text-align: center;
+    }
+
+    .input-group .input-group-text {
+      width: 31px;
+      min-height: 48px;
+      // font-size: 0.75rem;
+    }
+  }
+}

+ 3 - 1
ng/tsconfig.app.json

@@ -3,7 +3,9 @@
   "extends": "./tsconfig.json",
   "compilerOptions": {
     "outDir": "./out-tsc/app",
-    "types": []
+    "types": [
+      "@angular/localize"
+    ]
   },
   "files": ["src/main.ts", "src/polyfills.ts"],
   "include": ["src/**/*.d.ts", "../common/**/*.d.ts"]

+ 3 - 0
ng/tsconfig.json

@@ -22,6 +22,9 @@
       "es2020",
       "dom"
     ],
+    "types": [
+      "@angular/localize"
+    ],
     "useDefineForClassFields": false
   },
   "angularCompilerOptions": {

+ 2 - 1
ng/tsconfig.spec.json

@@ -4,7 +4,8 @@
   "compilerOptions": {
     "outDir": "./out-tsc/spec",
     "types": [
-      "jasmine"
+      "jasmine",
+      "@angular/localize"
     ]
   },
   "files": [

+ 32 - 0
server/docs/Monitoring.postman_collection.json

@@ -249,6 +249,38 @@
 					},
 					"response": []
 				},
+				{
+					"name": "/services/test - Run a test service check",
+					"request": {
+						"method": "POST",
+						"header": [],
+						"body": {
+							"mode": "raw",
+							"raw": "{\r\n    \"title\": \"coachbruele.de\",\r\n    \"type\": \"http\",\r\n    \"serverId\": 2,\r\n    \"url\": \"https://coachbruele.de\",\r\n    \"active\": true,\r\n    \"interval\": 150,\r\n    \"timeout\": 10000,\r\n    \"notify\": true,\r\n    \"notifyThreshold\": 3,\r\n    \"checks\": [\r\n        {\r\n            \"and\": [\r\n                \"coachbrueleeee\",\r\n                \"coachbrueleeee\\\\.de\",\r\n                \"<title>[^<]*(coachbrueleeee)[^<]*</title>\"\r\n            ]\r\n        }\r\n    ]\r\n}",
+							"options": {
+								"raw": {
+									"language": "json"
+								}
+							}
+						},
+						"url": {
+							"raw": "http://10.8.0.1:8880/services/test",
+							"protocol": "http",
+							"host": [
+								"10",
+								"8",
+								"0",
+								"1"
+							],
+							"port": "8880",
+							"path": [
+								"services",
+								"test"
+							]
+						}
+					},
+					"response": []
+				},
 				{
 					"name": "/fcm/topics/{:topic} - Send a (Test) Notification to a Topic",
 					"request": {

+ 1 - 1
server/package.json

@@ -5,7 +5,7 @@
   "main": "dist/server/src/index.js",
   "scripts": {
     "start": "npm run build && node .",
-    "build": "tsc -b && rm -rfv dist/server/src/migrations && cp -rv src/migrations dist/server/src/"
+    "build": "tsc -b && rm -rfv dist/server/src/migrations && tsc -p tsconfig.mig.json && cp -v src/migrations/*.sql dist/server/src/migrations/"
   },
   "author": "Christian Kahlau, HostBBQ ©2022",
   "license": "ISC",

+ 11 - 5
server/src/ctrl/database.class.ts

@@ -11,6 +11,7 @@ import { Logger } from '../../../common/util/logger.class';
 
 import { ValidationException } from '../lib/validation-exception.class';
 import { DBMigration } from './db-migration.class';
+import { HealthCheckDataProvider } from './health-check-data-provider.interface';
 import { SQLiteController } from './sqlite-controller.base';
 
 export enum ServiceChangedStatus {
@@ -21,7 +22,7 @@ export enum ServiceChangedStatus {
   Rescheduled
 }
 
-export class Database extends SQLiteController {
+export class Database extends SQLiteController implements HealthCheckDataProvider {
   public set onError(listener: (error: any) => void) {
     this._onError = listener;
   }
@@ -350,6 +351,7 @@ export class Database extends SQLiteController {
     try {
       if (oldConf) {
         // UPDATE
+        Logger.debug('[DEBUG] Updating HealthCheckConfig', conf.title, `(${oldConf.id})`);
         if (oldConf.title !== conf.title) {
           await this.stmt('UPDATE HealthCheckConfig SET Title = ? WHERE ID = ?', [conf.title, oldConf.id]);
         }
@@ -379,7 +381,7 @@ export class Database extends SQLiteController {
         const delIDs: number[] = [];
         res.rows.forEach((row, i) => {
           if (i < conf.checks.length) {
-            updValues.push([conf.checks[i], row['ID']]);
+            updValues.push([JSON.stringify(conf.checks[i]), row['ID']]);
           } else {
             delIDs.push(row['ID']);
           }
@@ -395,7 +397,7 @@ export class Database extends SQLiteController {
             await this.run('UPDATE HealthCheckParams SET Value = ? WHERE ID = ?;', data);
           }
         }
-        const insValues = conf.checks.filter((c, i) => i > res.rows.length - 1).map(c => [conf.id, 'regexp', 'check', c]);
+        const insValues = conf.checks.filter((c, i) => i > res.rows.length - 1).map(c => [conf.id, 'regexp', 'check', JSON.stringify(c)]);
         if (insValues.length) {
           for (const data of insValues) {
             await this.run('INSERT INTO HealthCheckParams(ConfigID, Type, Key, Value) VALUES(?, ?, ?, ?);', data);
@@ -403,6 +405,7 @@ export class Database extends SQLiteController {
         }
       } else {
         // INSERT
+        Logger.debug('[DEBUG] Inserting new HealthCheckConfig', conf.title);
         const res = await this.run('INSERT INTO HealthCheckConfig(ServerID, Type, Title) VALUES(?, ?, ?);', [serverID, 'http', conf.title]);
         conf.id = res.lastID;
         if (conf.active ?? defaults.serviceChecks.active) {
@@ -425,7 +428,7 @@ export class Database extends SQLiteController {
             ...[res.lastID, 'number', 'timeout', conf.timeout ?? defaults.serviceChecks.httpTimeout],
             ...[res.lastID, 'boolean', 'notify', conf.notify ?? defaults.serviceChecks.notify],
             ...[res.lastID, 'number', 'notifyThreshold', conf.notifyThreshold ?? defaults.serviceChecks.notifyThreshold],
-            ...conf.checks.reduce((ret, check) => [...ret, res.lastID, 'regexp', 'check', check], [] as any[])
+            ...conf.checks.reduce((ret, check) => [...ret, res.lastID, 'regexp', 'check', JSON.stringify(check)], [] as any[])
           ]
         );
       }
@@ -655,7 +658,10 @@ export class Database extends SQLiteController {
       timeout: (hcConf.params?.find(p => p.key === 'timeout')?.value as number) ?? defaults.serviceChecks.httpTimeout,
       notify: (hcConf.params?.find(p => p.key === 'notify')?.value as boolean) ?? defaults.serviceChecks.notify,
       notifyThreshold: (hcConf.params?.find(p => p.key === 'notifyThreshold')?.value as number) ?? defaults.serviceChecks.notifyThreshold,
-      checks: hcConf.params?.reduce((res, p) => (p.key === 'check' && Array.isArray(p.value) ? [...res, ...p.value] : res), [] as string[])
+      checks: hcConf.params?.reduce(
+        (res, p) => (p.key === 'check' && Array.isArray(p.value) ? [...res, ...p.value.map(c => JSON.parse(c))] : res),
+        [] as string[]
+      )
     };
     return {
       id: hcConf.id,

+ 40 - 16
server/src/ctrl/db-migration.class.ts

@@ -5,6 +5,7 @@ import { Database } from 'sqlite3';
 import { Logger } from '../../../common/util/logger.class';
 
 import { MigrationException } from '../lib/migration-exception.class';
+import { MigrationRunner } from '../lib/migration-runner.interface';
 import { SQLiteController } from './sqlite-controller.base';
 
 export class DBMigration extends SQLiteController {
@@ -26,7 +27,7 @@ export class DBMigration extends SQLiteController {
       Logger.debug('[DEBUG] lastid', lastID);
 
       for (const file of files) {
-        const m = /^(\d{12})_(.*)\.sql$/.exec(file);
+        const m = /^(\d{12})_(.*)\.(sql|js)$/.exec(file);
 
         if (!m) {
           throw new MigrationException(`File ${file} does not match migration file pattern. Aborted processing migrations.`);
@@ -35,21 +36,44 @@ export class DBMigration extends SQLiteController {
         const id = Number(m[1]);
         if (id <= lastID) continue;
 
-        const migFilepath = path.join(migrationsDir, file);
-        const migration = await fsp.readFile(migFilepath, { encoding: 'utf-8' });
-
-        Logger.info('[INFO] Applying DB migration', file);
-        await this.beginTransaction();
-        try {
-          await this.exec(migration);
-          await this.run('INSERT INTO db_migrations(id, title, migrated) VALUES(?, ?, ?);', [id, m[2], new Date().getTime()]);
-          await this.commit();
-          Logger.info('[INFO] DB migration', file, 'succeeded.');
-        } catch (error) {
-          Logger.error('[ERROR] DB migration failed at', file, '- Rolling back...');
-          await this.rollback();
-
-          throw error;
+        if (m[3] === 'sql') {
+          const migFilepath = path.join(migrationsDir, file);
+          const migration = await fsp.readFile(migFilepath, { encoding: 'utf-8' });
+
+          Logger.info('[INFO] Applying SQL DB migration', file);
+          await this.beginTransaction();
+          try {
+            await this.exec(migration);
+            await this.run('INSERT INTO db_migrations(id, title, migrated) VALUES(?, ?, ?);', [id, m[2], new Date().getTime()]);
+            await this.commit();
+            Logger.info('[INFO] DB migration', file, 'succeeded.');
+          } catch (error) {
+            Logger.error('[ERROR] DB migration failed at', file, '- Rolling back...');
+            await this.rollback();
+
+            throw error;
+          }
+        } else {
+          const migFilepath = path.join(migrationsDir, file.substring(0, file.length - 3));
+          const imp = require(migFilepath).default;
+          if (typeof imp !== 'function') {
+            throw new MigrationException(`File ${file} is not a valid <MigrationRunner> implementation!`);
+          }
+          const mig = imp as MigrationRunner;
+
+          Logger.info('[INFO] Applying TypeScript DB migration', file);
+          await this.beginTransaction();
+          try {
+            await mig(this);
+            await this.run('INSERT INTO db_migrations(id, title, migrated) VALUES(?, ?, ?);', [id, m[2], new Date().getTime()]);
+            await this.commit();
+            Logger.info('[INFO] DB migration', file, 'succeeded.');
+          } catch (error) {
+            Logger.error('[ERROR] DB migration failed at', file, '- Rolling back...');
+            await this.rollback();
+
+            throw error;
+          }
         }
       }
     }

+ 7 - 0
server/src/ctrl/health-check-data-provider.interface.ts

@@ -0,0 +1,7 @@
+import { HttpCheckData, HttpCheckStatus, ServiceCheckData } from '../../../common/lib/http-check-data.module';
+
+export interface HealthCheckDataProvider {
+  getHttpCheckConfigByID: (serverID: number, configID: number) => Promise<HttpCheckConfig | null>;
+  insertHealthCheckData: (confID: number, time: Date, status: HttpCheckStatus, message: string) => Promise<HttpCheckData>;
+  getLastErrors: (confID: number, threshold: number) => Promise<ServiceCheckData[]>;
+}

+ 77 - 18
server/src/ctrl/http-check-controller.class.ts

@@ -8,8 +8,10 @@ import { Logger } from '../../../common/util/logger.class';
 import { Timer } from '../timer.class';
 import { Database, ServiceChangedStatus } from './database.class';
 import { FCMController } from './fcm-controller.class';
+import { HealthCheckDataProvider } from './health-check-data-provider.interface';
 
 type Subscriber = { id: number; interval: number; conf: HttpCheckConfig };
+type ContentCheckError = { type: 'contentCheck'; status: HttpCheckStatus; message: string };
 
 export class HttpCheckController {
   private subscriptions: Array<Subscriber> = [];
@@ -29,7 +31,7 @@ export class HttpCheckController {
           this.scheduleCheck(conf);
 
           Logger.info('[INFO] Initial HTTP Service Check for', conf.title, '...');
-          await this.timerTick(conf);
+          await this.runCheck(conf, this.db);
         }
       } catch (err) {
         Logger.error('[FATAL] Initializing ServerConnector failed:', err);
@@ -63,7 +65,7 @@ export class HttpCheckController {
     if (Number.isNaN(interval)) interval = defaults.serviceChecks.interval;
 
     if (log) Logger.info(`[INFO] Starting HTTP Service Check Controller for "${conf.title}" with interval ${interval} seconds ...`);
-    const id = Timer.instance.subscribe(interval, async () => await this.timerTick(conf));
+    const id = Timer.instance.subscribe(interval, async () => await this.runCheck(conf, this.db));
     const sub = { id, interval, conf };
     this.subscriptions.push(sub);
     return sub;
@@ -83,7 +85,7 @@ export class HttpCheckController {
     this.subscriptions = this.subscriptions.filter(s => s.id !== sub.id);
   }
 
-  private async timerTick(conf: HttpCheckConfig) {
+  public async runCheck(conf: HttpCheckConfig, db: HealthCheckDataProvider) {
     Logger.debug('[DEBUG] TICK', new Date(), JSON.stringify(conf));
 
     const now = new Date();
@@ -94,7 +96,7 @@ export class HttpCheckController {
     let success = true;
     try {
       const id = conf.id;
-      conf = (await this.db.getHttpCheckConfigByID(conf.serverId ?? 0, id)) as HttpCheckConfig;
+      conf = (await db.getHttpCheckConfigByID(conf.serverId ?? 0, id)) as HttpCheckConfig;
 
       if (!conf) {
         Logger.warn(`[WARN] HealthCheckConfig(${id}) not found in Database but still scheduled in Timer!`);
@@ -104,20 +106,17 @@ export class HttpCheckController {
       options.timeout = conf.timeout;
       let response = await axios.get(conf.url, options);
       const responseText = new String(response.data).toString();
+      const errors = this.recurseDisjunctChecks(conf.checks, responseText);
 
-      for (const check of conf.checks) {
-        const reg = new RegExp(check, 'i');
-        if (!reg.test(responseText)) {
-          Logger.debug(`[DEBUG] Regular expression /${check}/i not found in response`);
-          await this.db.insertHealthCheckData(conf.id, now, HttpCheckStatus.CheckFailed, `Regular expression /${check}/i not found in response`);
-          success = false;
-        }
+      for (const error of errors) {
+        await db.insertHealthCheckData(conf.id, now, error.status, error.message);
+        success = false;
       }
 
       if (success) {
         if (conf.notify) {
           try {
-            const lastErrors = await this.db.getLastErrors(conf.id, conf.notifyThreshold + 1);
+            const lastErrors = await db.getLastErrors(conf.id, conf.notifyThreshold + 1);
             if (lastErrors.length > conf.notifyThreshold) {
               Logger.debug(`[DEBUG] Sending [RECOVERY] FCM Notification for`, conf.title);
               await FCMController.instance.sendNotificationToTopic(defaults.fcmTopics.services, {
@@ -131,7 +130,7 @@ export class HttpCheckController {
         }
 
         Logger.debug(`[DEBUG] HTTP Service Check "${conf.title}": OK.`);
-        await this.db.insertHealthCheckData(conf.id, now, HttpCheckStatus.OK, 'OK');
+        await db.insertHealthCheckData(conf.id, now, HttpCheckStatus.OK, 'OK');
       }
     } catch (err) {
       let log = false;
@@ -140,11 +139,11 @@ export class HttpCheckController {
         // err.code = 'ECONNREFUSED' | 'ECONNABORTED' | 'ERR_BAD_REQUEST' | 'ERR_BAD_RESPONSE' | ...?
         try {
           if (err.code === 'ECONNABORTED') {
-            await this.db.insertHealthCheckData(conf.id, now, HttpCheckStatus.Timeout, err.message);
+            await db.insertHealthCheckData(conf.id, now, HttpCheckStatus.Timeout, err.message);
           } else if (err.code && ['ERR_BAD_REQUEST', 'ERR_BAD_RESPONSE'].includes(err.code)) {
-            await this.db.insertHealthCheckData(conf.id, now, HttpCheckStatus.RequestFailed, `${err.response?.status} ${err.response?.statusText}`);
+            await db.insertHealthCheckData(conf.id, now, HttpCheckStatus.RequestFailed, `${err.response?.status} ${err.response?.statusText}`);
           } else {
-            await this.db.insertHealthCheckData(conf.id, now, HttpCheckStatus.RequestFailed, err.message);
+            await db.insertHealthCheckData(conf.id, now, HttpCheckStatus.RequestFailed, err.message);
           }
         } catch (insertErr) {
           Logger.error(`[ERROR] Inserting HealthCheckData on Error failed:`, insertErr);
@@ -152,7 +151,7 @@ export class HttpCheckController {
         }
       } else {
         try {
-          await this.db.insertHealthCheckData(conf.id, now, HttpCheckStatus.Unknown, new String(err).toString());
+          await db.insertHealthCheckData(conf.id, now, HttpCheckStatus.Unknown, new String(err).toString());
         } catch (insertErr) {
           Logger.error(`[ERROR] Inserting HealthCheckData on Error failed:`, insertErr);
         }
@@ -162,7 +161,7 @@ export class HttpCheckController {
     }
     if (!success && conf.notify && !process.env.DEV_MODE) {
       try {
-        const lastErrors = await this.db.getLastErrors(conf.id, conf.notifyThreshold + 1);
+        const lastErrors = await db.getLastErrors(conf.id, conf.notifyThreshold + 1);
         if (lastErrors.length > conf.notifyThreshold) {
           Logger.debug(`[DEBUG] Sending [CRIT] FCM Notification for`, conf.title);
           const lastCheck = lastErrors[0];
@@ -180,6 +179,66 @@ export class HttpCheckController {
     }
   }
 
+  private recurseDisjunctChecks(checks: CheckDisjunction, responseText: string): ContentCheckError[] {
+    const errorBuffer: ContentCheckError[] = [];
+    Logger.debug(`[DEBUG] Processing ${checks.length} disjunctive checks ...`);
+    for (const check of checks) {
+      const errors: ContentCheckError[] = [];
+      if (typeof check === 'string') {
+        try {
+          this.doCheck(check, responseText);
+        } catch (error: any) {
+          if (error.type === 'contentCheck') {
+            errors.push(error as ContentCheckError);
+          } else throw error;
+        }
+      } else if (Array.isArray(check)) {
+        errors.push(...this.recurseDisjunctChecks(check, responseText));
+      } else {
+        errors.push(...this.recurseConjunctChecks(check, responseText));
+      }
+
+      if (errors.length) {
+        errorBuffer.push(...errors);
+      } else {
+        return [];
+      }
+    }
+    Logger.debug(`[DEBUG] All disjunctive checks failed, collected ${errorBuffer.length} errors`);
+    return errorBuffer;
+  }
+
+  private recurseConjunctChecks(check: CheckConjunction, responseText: string): ContentCheckError[] {
+    const errorBuffer: ContentCheckError[] = [];
+    Logger.debug(`[DEBUG] Processing ${check.and.length} conjunctive checks ...`);
+    for (const con of check.and) {
+      try {
+        if (typeof con === 'string') {
+          this.doCheck(con, responseText);
+        } else if (Array.isArray(con)) {
+          errorBuffer.push(...this.recurseDisjunctChecks(con, responseText));
+        } else {
+          errorBuffer.push(...this.recurseConjunctChecks(con, responseText));
+        }
+      } catch (error: any) {
+        if (error.type === 'contentCheck') {
+          errorBuffer.push(error as ContentCheckError);
+        } else throw error;
+      }
+    }
+    Logger.debug(`[DEBUG] Ran through conjunctive checks, collected ${errorBuffer.length} errors`);
+    return errorBuffer;
+  }
+
+  private doCheck(check: string, responseText: string) {
+    const reg = new RegExp(check, 'i');
+    if (!reg.test(responseText)) {
+      Logger.debug(`[DEBUG] Regular expression /${check}/i not found in response`);
+      throw { type: 'contentCheck', status: HttpCheckStatus.CheckFailed, message: `Regular expression /${check}/i not found in response` };
+    }
+    Logger.debug(`[DEBUG] RegExp check /${check}/i successful ✔︎`);
+  }
+
   async close() {
     if (!this.db) return;
     await this.db.close();

+ 3 - 0
server/src/lib/migration-runner.interface.ts

@@ -0,0 +1,3 @@
+import { SQLiteController } from '../ctrl/sqlite-controller.base';
+
+export type MigrationRunner = (db: SQLiteController) => Promise<void>;

+ 30 - 0
server/src/migrations/202301302212_website_healtcheck_disjunctive_checks.ts

@@ -0,0 +1,30 @@
+import { SQLiteController } from '../ctrl/sqlite-controller.base';
+
+export default async function (db: SQLiteController) {
+  const result = await db.stmt("SELECT * FROM `HealthCheckParams` WHERE `Type` = 'regexp' AND `Key` = 'check'", []);
+  const idsToDelete: number[] = [];
+  const checksByConfId = result.rows.reduce((res, row) => {
+    idsToDelete.push(row['ID']);
+    const confId = row['ConfigID'];
+    if (!res[confId]) {
+      res[confId] = [];
+    }
+    res[confId].push(row['Value']);
+    return res;
+  }, {}) as { [confID: number]: string[] };
+
+  for (const entry of Object.entries(checksByConfId)) {
+    const confId = entry[0] as unknown as number;
+    const checks = entry[1];
+
+    const conjunction = { and: checks };
+
+    await db.stmt('INSERT INTO `HealthCheckParams`(`ConfigID`, `Type`, `Key`, `Value`) VALUES(?, ?, ?, ?)', [
+      confId,
+      'regexp',
+      'check',
+      JSON.stringify(conjunction)
+    ]);
+  }
+  await db.run(`DELETE FROM HealthCheckParams WHERE ID IN (${idsToDelete.map(() => '?').join(',')});`, idsToDelete);
+}

+ 15 - 0
server/src/migrations/202302131250_website_healthcheck_fix2.sql

@@ -0,0 +1,15 @@
+ALTER TABLE HealthCheckDataEntry RENAME TO HealthCheckDataEntry_OLD;
+CREATE TABLE HealthCheckDataEntry(
+  ID INTEGER PRIMARY KEY AUTOINCREMENT,
+  ConfigID INTEGER NOT NULL,
+  Timestamp INTEGER NOT NULL,
+  Status INTEGER NOT NULL,
+  Message TEXT,
+  CONSTRAINT FK_HealthCheckDataEntry_HealthCheckConfig
+    FOREIGN KEY(ConfigID) 
+    REFERENCES HealthCheckConfig(ID)
+    ON DELETE CASCADE
+    /* REMOVED: UNIQUE(ConfigID, Timestamp) */
+);
+INSERT INTO HealthCheckDataEntry SELECT * FROM HealthCheckDataEntry_OLD;
+DROP TABLE HealthCheckDataEntry_OLD;

+ 52 - 1
server/src/webhdl/services-api-handler.class.ts

@@ -1,10 +1,11 @@
 import { RouterOptions, json } from 'express';
 
-import { ServiceCheckData } from '../../../common/lib/http-check-data.module';
+import { HttpCheckData, HttpCheckStatus, ServiceCheckData, ServiceCheckDataEntry } from '../../../common/lib/http-check-data.module';
 import { HttpStatusException } from '../../../common/lib/http-status.exception';
 
 import { ControllerPool } from '../ctrl/controller-pool.interface';
 import { ServiceChangedStatus } from '../ctrl/database.class';
+import { HealthCheckDataProvider } from '../ctrl/health-check-data-provider.interface';
 import { WebHandler } from './web-handler.base';
 
 export class ServicesAPIHandler extends WebHandler {
@@ -124,6 +125,19 @@ export class ServicesAPIHandler extends WebHandler {
         next(err);
       }
     });
+
+    this.router.post('/test', async (req, res, next) => {
+      try {
+        const config = req.body as HttpCheckConfig;
+        const mockDB = new HealthCheckDatabaseMock(config);
+
+        await this.ctrlPool.httpChecks.runCheck(config, mockDB);
+
+        res.send(mockDB.log);
+      } catch (err) {
+        next(err);
+      }
+    });
   }
 
   private validateNumber(id: string, field: string) {
@@ -136,3 +150,40 @@ export class ServicesAPIHandler extends WebHandler {
     return num;
   }
 }
+
+class HealthCheckDatabaseMock implements HealthCheckDataProvider {
+  public log: HttpCheckData[] = [];
+
+  constructor(private config: HttpCheckConfig) {}
+
+  async getHttpCheckConfigByID(serverID: number, configID: number) {
+    return this.config;
+  }
+  async insertHealthCheckData(confID: number, time: Date, status: HttpCheckStatus, message: string) {
+    const logEntry = { configId: confID, id: new Date().getTime(), time, status, message };
+    this.log.push(logEntry);
+    return logEntry;
+  }
+  async getLastErrors(confID: number, threshold: number) {
+    if (this.log.length === 0) return [];
+
+    const mapByTimestamp = new Map<number, ServiceCheckDataEntry[]>();
+    mapByTimestamp.set(this.log[0].time.getTime(), this.log);
+
+    const errors: ServiceCheckData[] = [];
+    for (const entry of mapByTimestamp.entries()) {
+      const time = entry[0];
+      const data = entry[1];
+
+      const errorData = data.filter(d => d.status !== HttpCheckStatus.OK);
+      if (!errorData.length) break;
+
+      errors.push({
+        time: new Date(time),
+        data: errorData
+      });
+    }
+
+    return errors;
+  }
+}

+ 2 - 1
server/tsconfig.json

@@ -13,5 +13,6 @@
     },
     "typeRoots": ["../common/types"]
   },
-  "include": ["./src/*", "./src/**/*", "../common/*", "../common/**/*"]
+  "include": ["./src/*", "./src/**/*", "../common/*", "../common/**/*"],
+  "exclude": ["./src/migrations/*"]
 }

+ 16 - 0
server/tsconfig.mig.json

@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "strict": true,
+    "outDir": "./dist/server/src",
+    "esModuleInterop": true,
+    "moduleResolution": "node",
+    "module": "commonjs",
+    "target": "ES2016",
+    "sourceMap": false,
+    "baseUrl": "./src/migrations",
+    "paths": {
+      "*": ["node_modules/*", "src/*", "../common/*"]
+    }
+  },
+  "include": ["./src/migrations/*"]
+}