Forráskód Böngészése

admin-panel: run a test check, showing results as modal

Christian Kahlau 2 éve
szülő
commit
4141653001

+ 40 - 1
ng/src/app/components/service-check-form/service-check-form.component.html

@@ -79,7 +79,7 @@
   <div class="row border-top">
     <div class="col-12">
       <label class="mt-2">Check</label>
-      <div class="mt-2 position-relative border">
+      <div class="mt-2 position-relative border p-0">
         <app-service-check-disjunction
           [model]="serviceCheck.checks"
           [sortVisible]="{ up: false, down: false }"
@@ -88,3 +88,42 @@
     </div>
   </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>
+      </ng-container>
+    </ng-container>
+    <ng-template #testRunLoading>
+      <app-status-timeline-widget></app-status-timeline-widget>
+    </ng-template>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-outline-primary" (click)="modal.close(true)">Close</button>
+  </div>
+</ng-template>

+ 32 - 2
ng/src/app/components/service-check-form/service-check-form.component.ts

@@ -1,7 +1,9 @@
-import { Component, Input, OnInit, ViewChild } from '@angular/core';
+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 { 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';
@@ -14,6 +16,7 @@ import { ServiceApiService } from 'src/app/services/service-api.service';
 })
 export class ServiceCheckFormComponent implements OnInit {
   @ViewChild(ServiceCheckDisjunctionComponent) checkEditor!: ServiceCheckDisjunctionComponent;
+  @ViewChild('testModalContent') testModalContent!: TemplateRef<HTMLElement>;
 
   @Input() serviceCheck!: HttpCheckConfig;
   public fa = { plus: faPlusSquare, delete: faMinusSquare };
@@ -29,7 +32,9 @@ export class ServiceCheckFormComponent implements OnInit {
     notifyThreshold: 3
   });
 
-  constructor(private formBuilder: FormBuilder, private serviceApi: ServiceApiService) {}
+  testRun?: { config: HttpCheckConfig; loading: boolean; error?: any; result?: ServiceCheckData };
+
+  constructor(private formBuilder: FormBuilder, private modalService: NgbModal, private serviceApi: ServiceApiService) {}
 
   ngOnInit(): void {
     this.serviceCheckForm.patchValue(this.serviceCheck);
@@ -52,4 +57,29 @@ export class ServiceCheckFormComponent implements OnInit {
 
     return savedCheck;
   }
+
+  async test() {
+    try {
+      const copy = deepCopy(this.serviceCheckForm.value as HttpCheckConfig);
+      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;
+  }
 }

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

@@ -27,6 +27,12 @@
                   <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"

+ 7 - 2
ng/src/app/pages/admin-panel/admin-service-checks-page/admin-service-checks-page.component.ts

@@ -1,7 +1,7 @@
 import { Component, ViewChild } from '@angular/core';
 import { ValidationErrors } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { faAngleRight, faPlus, faSave, faServer, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
+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';
@@ -25,7 +25,7 @@ export class AdminServiceChecksPageComponent {
   public serverConfigs?: ServerConfig[];
   public serviceChecks: HttpCheckConfig[] = [];
   public loadingServiceChecks = false;
-  public fa = { save: faSave, server: faServer, angleRight: faAngleRight, plus: faPlus, trash: faTrashAlt };
+  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[] };
@@ -178,4 +178,9 @@ export class AdminServiceChecksPageComponent {
       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>

+ 9 - 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';
 
@@ -47,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) }))))
+    );
+  }
 }

+ 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": {

+ 2 - 1
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;
   }

+ 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[]>;
+}

+ 13 - 12
server/src/ctrl/http-check-controller.class.ts

@@ -8,6 +8,7 @@ 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 };
@@ -30,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);
@@ -64,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;
@@ -84,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();
@@ -95,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!`);
@@ -108,14 +109,14 @@ export class HttpCheckController {
       const errors = this.recurseDisjunctChecks(conf.checks, responseText);
 
       for (const error of errors) {
-        await this.db.insertHealthCheckData(conf.id, now, error.status, error.message);
+        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, {
@@ -129,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;
@@ -138,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);
@@ -150,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);
         }
@@ -160,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];

+ 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;
+  }
+}