Explorar o código

Service checks detail page; extracted status timeline bar component from dashboard widget; service check logs endpoint; show as log table; highlight time range; WIP: scroll paging

Christian Kahlau %!s(int64=2) %!d(string=hai) anos
pai
achega
71ab4a0f51
Modificáronse 26 ficheiros con 469 adicións e 93 borrados
  1. 3 1
      ng/src/app/app-routing.module.ts
  2. 2 2
      ng/src/app/app.component.html
  3. 10 2
      ng/src/app/app.module.ts
  4. 3 16
      ng/src/app/components/service-checks-widget/service-checks-widget.component.html
  5. 0 7
      ng/src/app/components/service-checks-widget/service-checks-widget.component.scss
  6. 3 56
      ng/src/app/components/service-checks-widget/service-checks-widget.component.ts
  7. 5 0
      ng/src/app/components/status-timeline-widget/status-timeline-data.d.ts
  8. 13 0
      ng/src/app/components/status-timeline-widget/status-timeline-widget.component.html
  9. 7 0
      ng/src/app/components/status-timeline-widget/status-timeline-widget.component.scss
  10. 23 0
      ng/src/app/components/status-timeline-widget/status-timeline-widget.component.spec.ts
  11. 12 0
      ng/src/app/components/status-timeline-widget/status-timeline-widget.component.ts
  12. 60 0
      ng/src/app/lib/conversions.lib.ts
  13. 51 0
      ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.html
  14. 0 0
      ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.scss
  15. 23 0
      ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.spec.ts
  16. 124 0
      ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.ts
  17. 8 0
      ng/src/app/pipes/reverse.pipe.spec.ts
  18. 11 0
      ng/src/app/pipes/reverse.pipe.ts
  19. 8 0
      ng/src/app/pipes/status-color.pipe.spec.ts
  20. 14 0
      ng/src/app/pipes/status-color.pipe.ts
  21. 2 2
      ng/src/app/services/server-api.service.ts
  22. 17 3
      ng/src/app/services/service-api.service.ts
  23. 3 3
      ng/src/index.html
  24. 25 0
      server/src/ctrl/database.class.ts
  25. 40 0
      server/src/webhdl/services-api-handler.class.ts
  26. 2 1
      server/tsconfig.json

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

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

+ 2 - 2
ng/src/app/app.component.html

@@ -1,7 +1,7 @@
 <app-header></app-header>
 
-<div class="container pt-5">
-  <div class="pt-3">
+<div class="container overflow-hidden position-relative pt-5">
+  <div class="h-100 position-relative pt-3">
     <router-outlet></router-outlet>
   </div>
 </div>

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

@@ -13,12 +13,16 @@ 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 { 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 { 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';
 
 import { BytePipe } from './pipes/byte.pipe';
 import { FaByTypePipe } from './pipes/fa-by-type.pipe';
-import { ServiceChecksWidgetComponent } from './components/service-checks-widget/service-checks-widget.component';
+import { ReversePipe } from './pipes/reverse.pipe';
+import { StatusColorPipe } from './pipes/status-color.pipe';
 
 @NgModule({
   declarations: [
@@ -28,11 +32,15 @@ import { ServiceChecksWidgetComponent } from './components/service-checks-widget
     FaByTypePipe,
     HeaderComponent,
     HomePageComponent,
+    ReversePipe,
     ServerDataChartComponent,
     ServerDataPageComponent,
     ServerMetricsWidgetComponent,
+    ServiceCheckDetailPageComponent,
     ServiceCheckFormComponent,
-    ServiceChecksWidgetComponent
+    ServiceChecksWidgetComponent,
+    StatusColorPipe,
+    StatusTimelineWidgetComponent
   ],
   imports: [
     AppRoutingModule,

+ 3 - 16
ng/src/app/components/service-checks-widget/service-checks-widget.component.html

@@ -1,21 +1,8 @@
 <div *ngIf="serviceChecks; else loading" class="d-flex flex-column">
-  <div *ngFor="let check of serviceChecks" class="position-relative mt-1 mb-1">
-    <ng-container *ngIf="check.data; else loading">
-      <div class="progress position-relative">
-        <div class="status-timeline-label">{{ check.title }}</div>
-        <div
-          *ngFor="let data of check.data"
-          [class]="'progress-bar ' + data.statusClass"
-          [title]="data.statusText"
-          [style.width]="data.width + '%'"></div>
-      </div>
-    </ng-container>
+  <div *ngFor="let check of serviceChecks" class="position-relative mt-1 mb-1" [routerLink]="'/svc/' + check.serverId + '/' + check.id">
+    <app-status-timeline-widget [title]="check.title" [data]="check.data"></app-status-timeline-widget>
   </div>
 </div>
 <ng-template #loading>
-  <div class="progress">
-    <div class="progress-bar bg-progress progress-bar-striped progress-bar-animated text-primary" role="progressbar" style="width: 100%">
-      Loading data
-    </div>
-  </div>
+  <app-status-timeline-widget title="loading"></app-status-timeline-widget>
 </ng-template>

+ 0 - 7
ng/src/app/components/service-checks-widget/service-checks-widget.component.scss

@@ -1,7 +0,0 @@
-.progress {
-  .status-timeline-label {
-    position: absolute;
-    left: 50%;
-    transform: translate(-50%, -2px);
-  }
-}

+ 3 - 56
ng/src/app/components/service-checks-widget/service-checks-widget.component.ts

@@ -1,15 +1,7 @@
 import { Component, Input } from '@angular/core';
-import { format } from 'date-fns';
-
-import { HttpCheckStatus, ServiceCheckData } from '../../../../../common/lib/http-check-data.module';
 
 import { ServiceApiService } from 'src/app/services/service-api.service';
-
-type StatusTimelineData = {
-  width: number;
-  statusText: string;
-  statusClass: string;
-};
+import { convertToStatusTimelineData } from 'src/app/lib/conversions.lib';
 
 @Component({
   selector: 'app-service-checks-widget',
@@ -43,62 +35,17 @@ export class ServiceChecksWidgetComponent {
 
       const end = new Date();
       const start = new Date(end.getTime() - 1000 * 60 * 60 * 24);
-      const diffMs = end.getTime() - start.getTime();
+
       this.serviceChecks?.forEach(async check => {
         // Query status data of last 24h
         const rawData = await this.serviceApi.queryServiceData(this.server.id, check.id, start, end);
 
         // Enhance data for displaying as stacked progress bar
-        const data: Partial<StatusTimelineData>[] = [];
-        if (rawData?.length) {
-          let lastEntry: Partial<StatusTimelineData> | undefined = undefined;
-          const diffPerc = ((rawData[0].time.getTime() - start.getTime()) / diffMs) * 100;
-          if (diffPerc > 0) {
-            lastEntry = {
-              statusText: `[${format(rawData[0].time, 'HH:mm:ss')}] ${rawData[0].data.map(dx => dx.message).join(', ')}`,
-              statusClass: this.mapStatusClass(rawData[0])
-            };
-            data.push(lastEntry);
-          }
-          let sumwidth = 0;
-          rawData.forEach((d, i) => {
-            if (lastEntry) {
-              lastEntry.width = ((d.time.getTime() - start.getTime()) / diffMs) * 100 - sumwidth;
-              sumwidth += lastEntry.width;
-            }
-
-            lastEntry = {
-              statusText: `[${format(d.time, 'HH:mm:ss')}] ${d.data.map(dx => dx.message).join(', ')}`,
-              statusClass: this.mapStatusClass(d)
-            };
-            data.push(lastEntry);
-          });
-
-          if (sumwidth < 100 && lastEntry && !lastEntry.width) {
-            lastEntry.width = 100 - sumwidth;
-          }
-        } else {
-          data.push({
-            width: 100,
-            statusClass: 'bg-progress',
-            statusText: '- no data -'
-          });
-        }
-        check.data = data as StatusTimelineData[];
+        check.data = convertToStatusTimelineData(start, end, rawData);
       });
     } catch (err) {
       // TODO
       console.error(err);
     }
   }
-
-  private mapStatusClass(data: ServiceCheckData) {
-    const maxStatus = data.data.reduce((res, d) => (res = Math.max(res, d.status)), 0);
-    switch (maxStatus) {
-      case HttpCheckStatus.OK:
-        return 'bg-peak';
-      default:
-        return 'bg-max';
-    }
-  }
 }

+ 5 - 0
ng/src/app/components/status-timeline-widget/status-timeline-data.d.ts

@@ -0,0 +1,5 @@
+type StatusTimelineData = {
+  width: number;
+  statusText: string;
+  statusClass: string;
+};

+ 13 - 0
ng/src/app/components/status-timeline-widget/status-timeline-widget.component.html

@@ -0,0 +1,13 @@
+<ng-container *ngIf="data; else loading">
+  <div class="progress position-relative">
+    <div *ngIf="mode === 'mini'" class="status-timeline-label">{{ title }}</div>
+    <div *ngFor="let data of data" [class]="'progress-bar ' + data.statusClass" [title]="data.statusText" [style.width]="data.width + '%'"></div>
+  </div>
+</ng-container>
+<ng-template #loading>
+  <div class="progress">
+    <div class="progress-bar bg-progress progress-bar-striped progress-bar-animated text-primary" role="progressbar" style="width: 100%">
+      Loading data
+    </div>
+  </div>
+</ng-template>

+ 7 - 0
ng/src/app/components/status-timeline-widget/status-timeline-widget.component.scss

@@ -0,0 +1,7 @@
+.progress {
+  .status-timeline-label {
+    position: absolute;
+    left: 50%;
+    transform: translate(-50%, -2px);
+  }
+}

+ 23 - 0
ng/src/app/components/status-timeline-widget/status-timeline-widget.component.spec.ts

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

+ 12 - 0
ng/src/app/components/status-timeline-widget/status-timeline-widget.component.ts

@@ -0,0 +1,12 @@
+import { Component, Input } from '@angular/core';
+
+@Component({
+  selector: 'app-status-timeline-widget',
+  templateUrl: './status-timeline-widget.component.html',
+  styleUrls: ['./status-timeline-widget.component.scss']
+})
+export class StatusTimelineWidgetComponent {
+  @Input() title = '';
+  @Input() data?: StatusTimelineData[];
+  @Input() mode: 'mini' | 'detail' = 'mini';
+}

+ 60 - 0
ng/src/app/lib/conversions.lib.ts

@@ -0,0 +1,60 @@
+import { format } from 'date-fns';
+import { HttpCheckStatus, ServiceCheckData } from '../../../../common/lib/http-check-data.module';
+
+/**
+ * // Enhance data for displaying as stacked progress bar
+ * @param start Start time (left boundary) for status timeline
+ * @param end End time (right boundary) for status timeline
+ * @param rawData The raw data array from the api response
+ * @returns Data for displaying as stacked progress bar using ServiceTimelineWidget[data]
+ */
+export function convertToStatusTimelineData(start: Date, end: Date, rawData?: ServiceCheckData[]) {
+  const diffMs = end.getTime() - start.getTime();
+
+  const data: Partial<StatusTimelineData>[] = [];
+  if (rawData?.length) {
+    let lastEntry: Partial<StatusTimelineData> | undefined = undefined;
+    const diffPerc = ((rawData[0].time.getTime() - start.getTime()) / diffMs) * 100;
+    if (diffPerc > 0) {
+      lastEntry = {
+        statusText: `[${format(rawData[0].time, 'HH:mm:ss')}] ${rawData[0].data.map(dx => dx.message).join(', ')}`,
+        statusClass: mapStatusClass(rawData[0])
+      };
+      data.push(lastEntry);
+    }
+    let sumwidth = 0;
+    rawData.forEach((d, i) => {
+      if (lastEntry) {
+        lastEntry.width = ((d.time.getTime() - start.getTime()) / diffMs) * 100 - sumwidth;
+        sumwidth += lastEntry.width;
+      }
+
+      lastEntry = {
+        statusText: `[${format(d.time, 'HH:mm:ss')}] ${d.data.map(dx => dx.message).join(', ')}`,
+        statusClass: mapStatusClass(d)
+      };
+      data.push(lastEntry);
+    });
+
+    if (sumwidth < 100 && lastEntry && !lastEntry.width) {
+      lastEntry.width = 100 - sumwidth;
+    }
+  } else {
+    data.push({
+      width: 100,
+      statusClass: 'bg-progress',
+      statusText: '- no data -'
+    });
+  }
+  return data as StatusTimelineData[];
+}
+
+export function mapStatusClass(data: ServiceCheckData) {
+  const maxStatus = data.data.reduce((res, d) => (res = Math.max(res, d.status)), 0);
+  switch (maxStatus) {
+    case HttpCheckStatus.OK:
+      return 'bg-peak';
+    default:
+      return 'bg-max';
+  }
+}

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

@@ -0,0 +1,51 @@
+<ng-container *ngIf="serviceCheck; else loading">
+  <h3>{{ serviceCheck.title }}</h3>
+
+  <div *ngIf="statusData" class="d-flex flex-column overflow-hidden">
+    <div class="position-relative">
+      <app-status-timeline-widget [data]="statusData" mode="detail"></app-status-timeline-widget>
+      <div *ngIf="log.renderHighlight" class="position-absolute h-100 w-100 top-0 left-0">
+        <div
+          style="background-color: rgb(79, 79, 79); opacity: 0.5"
+          class="h-100"
+          [style.margin-left]="log.renderHighlight.offsetLeft + '%'"
+          [style.width]="log.renderHighlight.width + '%'"></div>
+      </div>
+    </div>
+
+    <table class="table">
+      <colgroup>
+        <col width="180" />
+        <col width="*" />
+      </colgroup>
+      <thead>
+        <tr>
+          <th scope="col">Time</th>
+          <th scope="col">Messages</th>
+        </tr>
+      </thead>
+    </table>
+    <div *ngIf="log.start && log.end" class="overflow-auto" (scroll)="logScroll($event)" id="service-check-logs-scroller">
+      <table class="table">
+        <colgroup>
+          <col width="180" />
+          <col width="*" />
+        </colgroup>
+        <tbody *ngIf="log.entries">
+          <ng-container *ngFor="let entry of log.entries | reverse">
+            <tr [class]="entry | statusColor" [attr.data-time]="entry.time | date : 'yyyy-MM-ddTHH:mm:ss.SSSZZ'">
+              <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">
+              <td>{{ msg.message }}</td>
+            </tr>
+          </ng-container>
+        </tbody>
+      </table>
+    </div>
+  </div>
+</ng-container>
+<ng-template #loading>
+  <app-status-timeline-widget title="loading"></app-status-timeline-widget>
+</ng-template>

+ 0 - 0
ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.scss


+ 23 - 0
ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.spec.ts

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

+ 124 - 0
ng/src/app/pages/service-check-detail-page/service-check-detail-page.component.ts

@@ -0,0 +1,124 @@
+// Third party
+import { Component, ViewChild } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+// Common
+import { ServiceCheckData } from '../../../../../common/lib/http-check-data.module';
+
+// App
+import { convertToStatusTimelineData } from 'src/app/lib/conversions.lib';
+import { ServiceApiService } from 'src/app/services/service-api.service';
+
+@Component({
+  selector: 'app-service-check-detail-page',
+  templateUrl: './service-check-detail-page.component.html',
+  styleUrls: ['./service-check-detail-page.component.scss'],
+  host: { class: 'd-flex flex-column h-100' }
+})
+export class ServiceCheckDetailPageComponent {
+  private rawData: ServiceCheckData[] = [];
+  private params?: { serverID: number; serviceID: number };
+
+  public statusData?: StatusTimelineData[];
+  public serviceCheck?: HttpCheckConfig;
+
+  public log: {
+    start?: Date;
+    end?: Date;
+    visible?: { first: Date; last?: Date };
+    renderHighlight?: { offsetLeft: number; width: number };
+    entries?: ServiceCheckData[];
+    loading?: boolean;
+  } = {};
+
+  constructor(route: ActivatedRoute, private serviceApi: ServiceApiService) {
+    route.params.subscribe({
+      next: params => {
+        this.params = { serverID: Number(params['serverID']), serviceID: Number(params['serviceID']) };
+        this.load(this.params.serverID, this.params.serviceID);
+      }
+    });
+  }
+
+  async load(serverID: number, serviceID: number) {
+    try {
+      console.log('Loading Detail Page for Service Check:', { serverID, serviceID });
+
+      this.serviceCheck = await this.serviceApi.getServiceCheck(serverID, serviceID);
+
+      const end = new Date();
+      const start = new Date(end.getTime() - 1000 * 60 * 60 * 24);
+      this.rawData = await this.serviceApi.queryServiceData(serverID, serviceID, start, end);
+      this.statusData = convertToStatusTimelineData(start, end, this.rawData);
+
+      const pageEnd = end;
+      const pageStart = new Date(end.getTime() - 1000 * 60 * 60 * 4);
+      this.log.entries = await this.serviceApi.queryServiceLog(serverID, serviceID, pageStart, pageEnd);
+
+      this.log.end = end;
+      this.log.start = start;
+
+      setTimeout(this.logScroll.bind(this));
+    } catch (err) {
+      console.error(err);
+    }
+  }
+
+  logScroll(event?: Event) {
+    if (!this.log.start || !this.log.end) return;
+
+    const scrollContainer = (event ? event.target : document.getElementById('service-check-logs-scroller')) as HTMLDivElement;
+    this.log.visible = this.getVisibleTimespan(scrollContainer);
+
+    const absoluteWidth = this.log.end.getTime() - this.log.start.getTime();
+    this.log.renderHighlight = {
+      offsetLeft: ((this.log.visible.first.getTime() - this.log.start.getTime()) / absoluteWidth) * 100,
+      width: (((this.log.visible.last ?? this.log.visible.first).getTime() - this.log.visible.first.getTime()) / absoluteWidth) * 100
+    };
+
+    if (this.log.visible.first.getTime() === this.log.entries?.[0]?.time.getTime()) {
+      this.reloadOnScroll();
+    }
+  }
+
+  private async reloadOnScroll() {
+    if (this.log.loading) return;
+    if (!this.log.start || !this.log.end || !this.params || !this.log.visible) return;
+    try {
+      this.log.loading = true;
+
+      const end = new Date(this.log.visible.first.getTime() - 1000);
+      let start = new Date(end.getTime() - 1000 * 60 * 60 * 2);
+
+      if (start.getTime() < this.log.start.getTime()) start = this.log.start;
+
+      const entries = await this.serviceApi.queryServiceLog(this.params.serverID, this.params.serviceID, start, end);
+
+      // TODO: Problem: This runs into infinite reloading if entries.length is 0 here (because nothing got logged in the last hour)
+
+      this.log.entries = [...entries, ...(this.log.entries ?? [])];
+
+      this.log.loading = false;
+
+      setTimeout(this.logScroll.bind(this), 1000);
+    } catch (err) {
+      console.error(err);
+      this.log.loading = false;
+    }
+  }
+
+  private getVisibleTimespan(scrollContainer: HTMLDivElement) {
+    const trsInView: HTMLTableRowElement[] = [];
+    scrollContainer.querySelectorAll('tr').forEach(tr => {
+      if (tr.offsetTop + tr.clientHeight >= scrollContainer.scrollTop && tr.offsetTop <= scrollContainer.clientHeight + scrollContainer.scrollTop) {
+        trsInView.push(tr);
+      }
+    });
+
+    const datesInView = trsInView.map(tr => new Date(tr.dataset['time'] ?? ''));
+    const first = datesInView.reduce((res, cur) => (cur.getTime() < res ? cur.getTime() : res), Number.MAX_SAFE_INTEGER);
+    const last = datesInView.reduce((res, cur) => (cur.getTime() > res ? cur.getTime() : res), Number.MIN_SAFE_INTEGER);
+
+    return { first: new Date(first), last: new Date(last) };
+  }
+}

+ 8 - 0
ng/src/app/pipes/reverse.pipe.spec.ts

@@ -0,0 +1,8 @@
+import { ReversePipe } from './reverse.pipe';
+
+describe('ReversePipe', () => {
+  it('create an instance', () => {
+    const pipe = new ReversePipe();
+    expect(pipe).toBeTruthy();
+  });
+});

+ 11 - 0
ng/src/app/pipes/reverse.pipe.ts

@@ -0,0 +1,11 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'reverse'
+})
+export class ReversePipe implements PipeTransform {
+  transform<T>(value?: T[]) {
+    if (!value) return value;
+    return value.slice().reverse();
+  }
+}

+ 8 - 0
ng/src/app/pipes/status-color.pipe.spec.ts

@@ -0,0 +1,8 @@
+import { StatusColorPipe } from './status-color.pipe';
+
+describe('StatusColorPipe', () => {
+  it('create an instance', () => {
+    const pipe = new StatusColorPipe();
+    expect(pipe).toBeTruthy();
+  });
+});

+ 14 - 0
ng/src/app/pipes/status-color.pipe.ts

@@ -0,0 +1,14 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { ServiceCheckData } from '../../../../common/lib/http-check-data.module';
+
+import { mapStatusClass } from 'src/app/lib/conversions.lib';
+
+@Pipe({
+  name: 'statusColor'
+})
+export class StatusColorPipe implements PipeTransform {
+  transform(value?: ServiceCheckData, ...args: unknown[]) {
+    return !!value ? mapStatusClass(value) : 'bg-max';
+  }
+}

+ 2 - 2
ng/src/app/services/server-api.service.ts

@@ -42,7 +42,7 @@ export class ServerApiService {
     return firstValueFrom(
       this.http
         .get<QueryResponse<ServerData[]>>(`${environment.apiBaseUrl}server/${serverID}/data`, {
-          params: { type, start: start.toString(), end: end.toString() }
+          params: { type, start: start.toISOString(), end: end.toISOString() }
         })
         .pipe(map(resp => resp.data.map(data => ({ ...data, time: new Date(data.time) }))))
     );
@@ -52,7 +52,7 @@ export class ServerApiService {
     return firstValueFrom(
       this.http
         .get<QueryResponse<ReducedValuesPerc>>(`${environment.apiBaseUrl}server/${serverID}/stats`, {
-          params: { type, start: start.toString(), end: end.toString() }
+          params: { type, start: start.toISOString(), end: end.toISOString() }
         })
         .pipe(map(resp => resp.data))
     );

+ 17 - 3
ng/src/app/services/service-api.service.ts

@@ -12,8 +12,12 @@ import { environment } from 'src/environments/environment';
 export class ServiceApiService {
   constructor(private http: HttpClient) {}
 
-  public loadServiceChecks(serverId: number): Promise<HttpCheckConfig[]> {
-    return firstValueFrom(this.http.get<HttpCheckConfig[]>(`${environment.apiBaseUrl}services/${serverId}`));
+  public loadServiceChecks(serverID: number): Promise<HttpCheckConfig[]> {
+    return firstValueFrom(this.http.get<HttpCheckConfig[]>(`${environment.apiBaseUrl}services/${serverID}`));
+  }
+
+  public getServiceCheck(serverID: number, serviceID: number) {
+    return firstValueFrom(this.http.get<HttpCheckConfig>(`${environment.apiBaseUrl}services/${serverID}/${serviceID}`));
   }
 
   public saveServiceCheck(serverId: number, checkConfig: HttpCheckConfig): Promise<HttpCheckConfig> {
@@ -24,7 +28,17 @@ export class ServiceApiService {
     return firstValueFrom(
       this.http
         .get<QueryResponse<ServiceCheckData[]>>(`${environment.apiBaseUrl}services/${serverID}/${serviceID}/data`, {
-          params: { start: start.toString(), end: end.toString() }
+          params: { start: start.toISOString(), end: end.toISOString() }
+        })
+        .pipe(map(resp => resp.data.map(data => ({ ...data, time: new Date(data.time) })) as ServiceCheckData[]))
+    );
+  }
+
+  public queryServiceLog(serverID: number, serviceID: number, start: Date, end: Date) {
+    return firstValueFrom(
+      this.http
+        .get<QueryResponse<ServiceCheckData[]>>(`${environment.apiBaseUrl}services/${serverID}/${serviceID}/logs`, {
+          params: { start: start.toISOString(), end: end.toISOString() }
         })
         .pipe(map(resp => resp.data.map(data => ({ ...data, time: new Date(data.time) })) as ServiceCheckData[]))
     );

+ 3 - 3
ng/src/index.html

@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="en">
+<html lang="en" class="h-100">
   <head>
     <meta charset="utf-8" />
     <title>HostBBQ Monitoring Web UI</title>
@@ -7,7 +7,7 @@
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <link rel="icon" type="image/x-icon" href="favicon.ico" />
   </head>
-  <body>
-    <app-root></app-root>
+  <body class="h-100">
+    <app-root class="d-flex flex-column h-100"></app-root>
   </body>
 </html>

+ 25 - 0
server/src/ctrl/database.class.ts

@@ -530,6 +530,31 @@ export class Database extends SQLiteController {
     return arr;
   }
 
+  public async queryServiceCheckLogs(serverID: number, confID: number, from: Date, to: Date) {
+    const result = await this.stmt(
+      `
+      SELECT HealthCheckDataEntry.*
+      FROM HealthCheckConfig
+      JOIN HealthCheckDataEntry ON HealthCheckDataEntry.ConfigID = HealthCheckConfig.ID
+      WHERE HealthCheckConfig.ID = ?
+        AND HealthCheckConfig.ServerID = ?
+        AND HealthCheckDataEntry.Timestamp BETWEEN ? AND ?
+      ORDER BY Timestamp, ConfigID;`,
+      [confID, serverID, from.getTime(), to.getTime()]
+    );
+
+    const mapByTimestamp = this.mapServiceCheckDataByTimestamp(result.rows);
+
+    const arr: ServiceCheckData[] = [];
+    for (const entry of mapByTimestamp.entries()) {
+      arr.push({
+        time: new Date(entry[0]),
+        data: entry[1]
+      });
+    }
+    return arr;
+  }
+
   public async getLastErrors(confID: number, threshold: number) {
     const result = await this.stmt(
       `SELECT * FROM HealthCheckDataEntry

+ 40 - 0
server/src/webhdl/services-api-handler.class.ts

@@ -58,6 +58,19 @@ export class ServicesAPIHandler extends WebHandler {
       }
     });
 
+    this.router.get('/:serverID/:serviceID', async (req, res, next) => {
+      try {
+        const serverID = this.validateNumber(req.params.serverID, 'server id');
+        const serviceID = this.validateNumber(req.params.serviceID, 'service id');
+
+        const result = await this.ctrlPool.db.getHttpCheckConfigByID(serverID, serviceID);
+
+        res.send(result);
+      } catch (err) {
+        next(err);
+      }
+    });
+
     this.router.get('/:serverID/:serviceID/data', async (req, res, next) => {
       try {
         const serverID = this.validateNumber(req.params.serverID, 'server id');
@@ -84,6 +97,33 @@ export class ServicesAPIHandler extends WebHandler {
         next(err);
       }
     });
+
+    this.router.get('/:serverID/:serviceID/logs', async (req, res, next) => {
+      try {
+        const serverID = this.validateNumber(req.params.serverID, 'server id');
+        const serviceID = this.validateNumber(req.params.serviceID, 'service id');
+
+        const qStart = (req.query.start || '').toString();
+        const qEnd = (req.query.end || '').toString();
+
+        if (!qStart || !qEnd) throw new HttpStatusException("QueryParams 'start' and 'end' are mandatory.", 400);
+
+        const start = new Date(qStart);
+        const end = new Date(qEnd);
+        if ([start.toString(), end.toString()].includes('Invalid Date')) {
+          throw new HttpStatusException("QueryParams 'start' and 'end' must be parseable dates or unix epoch timestamps (ms).", 400);
+        }
+
+        const data = await this.ctrlPool.db.queryServiceCheckLogs(serverID, serviceID, start, end);
+        res.send({
+          start,
+          end,
+          data
+        } as QueryResponse<ServiceCheckData[]>);
+      } catch (err) {
+        next(err);
+      }
+    });
   }
 
   private validateNumber(id: string, field: string) {

+ 2 - 1
server/tsconfig.json

@@ -10,7 +10,8 @@
     "baseUrl": "./src",
     "paths": {
       "*": ["node_modules/*", "src/*", "../common/*"]
-    }
+    },
+    "typeRoots": ["../common/types"]
   },
   "include": ["./src/*", "./src/**/*", "../common/*", "../common/**/*"]
 }