Bläddra i källkod

Merge branch 'master' into feature/admin-panel

# Conflicts:
#	ng/src/app/app.module.ts
Christian Kahlau 3 år sedan
förälder
incheckning
baecff20b4

+ 0 - 34
ng/src/app/app.component.html

@@ -1,40 +1,6 @@
 <app-header></app-header>
 
 <div class="container pt-5">
-  <ul class="nav flex-row">
-    <li class="flex-fill">
-      <div class="card h-100">
-        <div class="card-header btn btn-toolbar bg-primary text-light" routerLink="/">
-          <fa-icon [icon]="fa.faChalkboard" class="pe-2"></fa-icon>
-          <span class="flex-fill text-start">Dashboard</span>
-          <fa-icon [class.hidden]="currentUrl !== '/'" [icon]="fa.faAngleDown" class="ps-2"></fa-icon>
-        </div>
-        <div class="card-body p-1">- soon come -</div>
-      </div>
-    </li>
-    <li *ngFor="let server of serverConfigs" class="flex-fill">
-      <div class="card h-100">
-        <div class="card-header btn btn-toolbar bg-primary text-light" [routerLink]="'/srv/' + server.id">
-          <fa-icon [icon]="fa.faServer" class="pe-2"></fa-icon>
-          <span class="flex-fill text-start">{{ server.title }}</span>
-          <fa-icon [class.hidden]="currentUrl !== '/srv/' + server.id" [icon]="fa.faAngleDown" class="ps-2"></fa-icon>
-        </div>
-        <div class="card-body p-1" aria-expanded="true">
-          <div *ngFor="let type of server.types" class="badge bg-primary me-1">
-            <div class="d-flex flex-column">
-              <div class="d-flex flex-row text-uppercase"><fa-icon [icon]="type.type | faType" class="pe-2 status-ok"></fa-icon>{{ type.type }}</div>
-              <ul *ngIf="type.subtypes" class="list-unstyled text-start subtypes-list">
-                <li *ngFor="let sub of type.subtypes" class="status-ok">
-                  {{ sub.type }}
-                </li>
-              </ul>
-            </div>
-          </div>
-        </div>
-      </div>
-    </li>
-  </ul>
-
   <div class="pt-3">
     <router-outlet></router-outlet>
   </div>

+ 0 - 38
ng/src/app/app.component.scss

@@ -1,38 +0,0 @@
-ul.nav {
-  .hidden {
-    visibility: hidden;
-  }
-
-  fa-icon {
-    &.status-ok {
-      color: var(--bs-light);
-    }
-    &.status-warn {
-      color: var(--bs-warning);
-    }
-    &.status-error {
-      color: var(--bs-danger);
-    }
-  }
-
-  .list-unstyled.subtypes-list {
-    padding-left: 0;
-
-    li::before {
-      content: '\25cf'; // ●
-      font-size: larger;
-    }
-
-    li.status-ok::before {
-      color: var(--bs-light);
-    }
-
-    li.status-warn::before {
-      color: var(--bs-warning);
-    }
-
-    li.status-error::before {
-      color: var(--bs-danger);
-    }
-  }
-}

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

@@ -1,7 +1,4 @@
 import { Component, OnInit } from '@angular/core';
-import { ActivationEnd, Router } from '@angular/router';
-import { faAngleDown, faAngleRight, faChalkboard, faServer } from '@fortawesome/free-solid-svg-icons';
-import { filter, Subscription } from 'rxjs';
 
 import { ServerApiService } from './services/server-api.service';
 
@@ -11,23 +8,7 @@ import { ServerApiService } from './services/server-api.service';
   styleUrls: ['./app.component.scss']
 })
 export class AppComponent implements OnInit {
-  private subscriptions: Subscription[] = [];
-
-  public fa = { faAngleDown, faAngleRight, faChalkboard, faServer };
-  public serverConfigs: ServerConfig[] = [];
-
-  public currentUrl: string = '/';
-
-  constructor(private apiService: ServerApiService, router: Router) {
-    this.subscriptions.push(
-      this.apiService.serverConfigs$.subscribe({ next: this.onServerConfigs.bind(this) }),
-      router.events.pipe(filter(e => e instanceof ActivationEnd)).subscribe({
-        next: e => {
-          this.currentUrl = '/' + (e as ActivationEnd).snapshot.url.map(seg => seg.path).join('/');
-        }
-      })
-    );
-  }
+  constructor(private apiService: ServerApiService) {}
 
   async ngOnInit() {
     try {
@@ -37,8 +18,4 @@ export class AppComponent implements OnInit {
       console.error(err);
     }
   }
-
-  private onServerConfigs(data: ServerConfig[]) {
-    this.serverConfigs = data;
-  }
 }

+ 9 - 7
ng/src/app/app.module.ts

@@ -10,16 +10,18 @@ import { AppRoutingModule } from './app-routing.module';
 import { AppComponent } from './app.component';
 import { AdminPanelComponent } from './components/admin/admin-panel/admin-panel.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 { ServiceCheckFormComponent } from './components/service-check-form/service-check-form.component';
 import { HomePageComponent } from './pages/home-page/home-page.component';
-import { FaByTypePipe } from './pipes/fa-by-type.pipe';
 import { ServerDataPageComponent } from './pages/server-data-page/server-data-page.component';
-import { BytePipe } from './pipes/byte.pipe';
-import { ServerDataChartComponent } from './components/server-data-chart/server-data-chart.component';
 
-import { ServiceCheckFormComponent } from './components/service-check-form/service-check-form.component';
+import { BytePipe } from './pipes/byte.pipe';
+import { FaByTypePipe } from './pipes/fa-by-type.pipe';
 
 @NgModule({
   declarations: [
+    AdminPanelComponent,
     AppComponent,
     BytePipe,
     FaByTypePipe,
@@ -27,18 +29,18 @@ import { ServiceCheckFormComponent } from './components/service-check-form/servi
     HomePageComponent,
     ServerDataChartComponent,
     ServerDataPageComponent,
-    AdminPanelComponent,
+    ServerMetricsWidgetComponent,
     ServiceCheckFormComponent
   ],
   imports: [
     AppRoutingModule,
     BrowserModule,
     FontAwesomeModule,
+    FormsModule,
     HttpClientModule,
     NgbAccordionModule,
-    NgChartsModule,
     NgbNavModule,
-    FormsModule,
+    NgChartsModule,
     ReactiveFormsModule
   ],
   providers: [{ provide: BytePipe, multi: false }],

+ 25 - 0
ng/src/app/components/server-metrics-widget/server-metrics-widget.component.html

@@ -0,0 +1,25 @@
+<div
+  class="d-grid"
+  style="grid-template-columns: max-content auto"
+  [style.grid-template-rows]="'repeat(' + dataTypes.length + ')'"
+  [routerLink]="'/srv/' + server.id">
+  <ng-container *ngFor="let type of dataTypes; index as i">
+    <div class="d-inline text-uppercase flex-grow-0 pe-2" style="grid-column: 1 / span 1" [style.grid-row]="i + 1 + ' / span 1'">
+      <fa-icon [icon]="type.type | faType" class="pe-2 status-ok"></fa-icon>{{ type.type }}
+    </div>
+    <div class="pt-1 pb-1 h-100" style="grid-column: 2 / span 1" [style.grid-row]="i + 1 + ' / span 1'">
+      <div *ngIf="type.data; else loading" class="progress position-relative">
+        <div *ngIf="type.data.avg" class="progress-bar bg-avg" role="progressbar" [style.width]="type.data.avg + '%'"></div>
+        <div *ngIf="type.data.peak" class="progress-bar bg-peak" role="progressbar" [style.width]="type.data.peak - (type.data.avg || 0) + '%'"></div>
+        <span *ngIf="type.subtype" class="position-absolute end-0 pe-2">:{{ type.subtype }}</span>
+      </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>
+      </ng-template>
+    </div>
+  </ng-container>
+</div>

+ 0 - 0
ng/src/app/components/server-metrics-widget/server-metrics-widget.component.scss


+ 23 - 0
ng/src/app/components/server-metrics-widget/server-metrics-widget.component.spec.ts

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

+ 55 - 0
ng/src/app/components/server-metrics-widget/server-metrics-widget.component.ts

@@ -0,0 +1,55 @@
+import { Component, Input } from '@angular/core';
+import { faHardDrive } from '@fortawesome/free-solid-svg-icons';
+import { Subscription } from 'rxjs';
+
+import { ServerApiService } from 'src/app/services/server-api.service';
+
+type FlatServerDataTypes = {
+  type: string;
+  subtype?: string;
+  data?: ReducedValuesPerc;
+};
+
+@Component({
+  selector: 'app-server-metrics-widget',
+  templateUrl: './server-metrics-widget.component.html',
+  styleUrls: ['./server-metrics-widget.component.scss']
+})
+export class ServerMetricsWidgetComponent {
+  @Input() set server(server: ServerConfig) {
+    this._server = server;
+
+    if (!this._typeSubscription) {
+      this._typeSubscription = this.apiService.serverDataTypes$.get(server.id).subscribe({ next: this.onServerDataTypes.bind(this) });
+    }
+  }
+  public get server() {
+    return this._server;
+  }
+
+  public get dataTypes() {
+    return this._dataTypes;
+  }
+
+  public fa = { faHardDrive };
+
+  private _server!: ServerConfig;
+  private _dataTypes: FlatServerDataTypes[] = [];
+  private _typeSubscription?: Subscription;
+
+  constructor(private apiService: ServerApiService) {}
+
+  private async onServerDataTypes(types: ServerDataTypesConfig[]) {
+    this._dataTypes = types.reduce((res, type) => {
+      res.push(...(type.subtypes?.map(sub => ({ type: type.type, subtype: sub.type })) ?? [{ type: type.type }]));
+      return res;
+    }, [] as FlatServerDataTypes[]);
+
+    const end = new Date();
+    const start = new Date(end.getTime() - 1000 * 60 * 60 * 24);
+    for (const type of this._dataTypes) {
+      // Fetch 24h stats of this dataType
+      type.data = await this.apiService.queryServerStats(this.server.id, type.type + (type.subtype ? `:${type.subtype}` : ''), start, end);
+    }
+  }
+}

+ 36 - 1
ng/src/app/pages/home-page/home-page.component.html

@@ -1 +1,36 @@
-<p>Dashboard works!</p>
+<div
+  class="d-grid"
+  [style.grid-template-columns]="'250px repeat(' + (grid.columns - 1) + ', 1fr)'"
+  [style.grid-template-rows]="'repeat(' + grid.rows + ', max-content)'">
+  <div class="card-header btn btn-toolbar bg-light text-primary" routerLink="/" style="grid-column: 1 / span 1; grid-row: 1 / span 1">
+    <fa-icon [icon]="fa.faChalkboard" class="pe-2"></fa-icon>
+    <span class="flex-fill text-start">Dashboard</span>
+    <fa-icon [icon]="fa.faAngleRight" class="ps-2"></fa-icon>
+  </div>
+  <div class="card-header bg-primary text-light" style="grid-column: 2 / span 1; grid-row: 1 / span 1">
+    <fa-icon [icon]="fa.faServer" class="pe-2"></fa-icon>
+    <span class="flex-fill">Server Metrics (24h)</span>
+  </div>
+  <div class="card-header bg-primary text-light" style="grid-column: 3 / span 1; grid-row: 1 / span 1">
+    <fa-icon [icon]="fa.faServer" class="pe-2"></fa-icon>
+    <span class="flex-fill">Service Status</span>
+  </div>
+
+  <ng-container *ngFor="let server of serverConfigs; index as i">
+    <div class="card-header bg-primary text-light" style="grid-column: 1 / span 1" [style.grid-row]="i + 2 + ' / span 1'">
+      <fa-icon [icon]="fa.faServer" class="pe-2"></fa-icon>
+      <span class="flex-fill text-start">{{ server.title }}</span>
+    </div>
+
+    <app-server-metrics-widget
+      class="d-block p-1 border-end border-bottom text-primary pointer link-panel-highlight"
+      style="grid-column: 2 / span 1"
+      [style.grid-row]="i + 2 + ' / span 1'"
+      [server]="server">
+    </app-server-metrics-widget>
+
+    <div class="p-1 border-end border-bottom" style="grid-column: 3 / span 1" [style.grid-row]="i + 2 + ' / span 1'">
+      ... service checks soon come...
+    </div>
+  </ng-container>
+</div>

+ 3 - 0
ng/src/app/pages/home-page/home-page.component.scss

@@ -0,0 +1,3 @@
+.link-panel-highlight:hover {
+  background-color: #f6f7f9;
+}

+ 17 - 1
ng/src/app/pages/home-page/home-page.component.ts

@@ -1,4 +1,5 @@
 import { Component, OnInit } from '@angular/core';
+import { faAngleDown, faAngleRight, faChalkboard, faServer } from '@fortawesome/free-solid-svg-icons';
 
 import { ServerApiService } from '../../services/server-api.service';
 
@@ -8,7 +9,22 @@ import { ServerApiService } from '../../services/server-api.service';
   styleUrls: ['./home-page.component.scss']
 })
 export class HomePageComponent implements OnInit {
-  constructor(private serverApi: ServerApiService) {}
+  public fa = { faAngleDown, faAngleRight, faChalkboard, faServer };
+  public serverConfigs: ServerConfig[] = [];
+
+  public grid = {
+    columns: 3,
+    rows: 1
+  };
+
+  constructor(private apiService: ServerApiService) {
+    this.apiService.serverConfigs$.subscribe({ next: this.onServerConfigs.bind(this) });
+  }
 
   async ngOnInit(): Promise<void> {}
+
+  private onServerConfigs(data: ServerConfig[]) {
+    this.serverConfigs = data;
+    this.grid.rows = this.serverConfigs.length + 1;
+  }
 }

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

@@ -47,4 +47,14 @@ export class ServerApiService {
         .pipe(map(resp => resp.data.map(data => ({ ...data, time: new Date(data.time) }))))
     );
   }
+
+  public queryServerStats(serverID: number, type: string, start: Date, end: Date) {
+    return firstValueFrom(
+      this.http
+        .get<QueryResponse<ReducedValuesPerc>>(`${environment.apiBaseUrl}server/${serverID}/stats`, {
+          params: { type, start: start.toString(), end: end.toString() }
+        })
+        .pipe(map(resp => resp.data))
+    );
+  }
 }

+ 28 - 0
ng/src/styles.scss

@@ -3,3 +3,31 @@
 .pointer {
   cursor: pointer;
 }
+
+.bg-avg {
+  background-color: #ffe69c !important;
+}
+
+.bg-peak {
+  background-color: #cce7ff !important;
+}
+
+.bg-max {
+  background-color: #e3a3a9 !important;
+}
+
+.bg-progress {
+  background-color: #e9ecef !important;
+}
+
+.text-avg {
+  color: #cc9a06 !important;
+}
+
+.text-peak {
+  color: #005299 !important;
+}
+
+.text-max {
+  color: #941320 !important;
+}

+ 39 - 1
server/docs/Monitoring.postman_collection.json

@@ -72,6 +72,44 @@
 					},
 					"response": []
 				},
+				{
+					"name": "/server/{:id}/stats? - Query Server Stats",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "http://10.8.0.1:8880/server/3/stats?type=ram&start=2022-12-28T16:00:00.000Z&end=2022-12-29T15:59:59.999Z",
+							"protocol": "http",
+							"host": [
+								"10",
+								"8",
+								"0",
+								"1"
+							],
+							"port": "8880",
+							"path": [
+								"server",
+								"3",
+								"stats"
+							],
+							"query": [
+								{
+									"key": "type",
+									"value": "ram"
+								},
+								{
+									"key": "start",
+									"value": "2022-12-28T16:00:00.000Z"
+								},
+								{
+									"key": "end",
+									"value": "2022-12-29T15:59:59.999Z"
+								}
+							]
+						}
+					},
+					"response": []
+				},
 				{
 					"name": "/server/{:id}/data/types - Get available Server Data Types",
 					"request": {
@@ -212,7 +250,7 @@
 					"response": []
 				},
 				{
-					"name": "/fcm/topics/{:topic}",
+					"name": "/fcm/topics/{:topic} - Send a (Test) Notification to a Topic",
 					"request": {
 						"method": "POST",
 						"header": [],

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

@@ -247,6 +247,47 @@ export class Database extends SQLiteController {
     return result.rows.map(r => ({ time: new Date(r.Timegroup), avg: r.avg, peak: r.peak, max: r.max }));
   }
 
+  public async queryServerStats(serverID: number, type: ServerDataType, from: Date, to: Date): Promise<ReducedValuesPerc> {
+    const select_max = type !== 'cpu';
+    const select_types = select_max ? [type, type, type] : [type, type];
+    const result = await this.stmt(
+      `
+      SELECT 
+        AVG(VALUE_AVG.Value) as 'avg',
+        AVG(VALUE_PEAK.Value) as 'peak'${
+          select_max
+            ? `,
+        MAX(VALUE_MAX.Value) as 'max'`
+            : ''
+        }
+      FROM ServerDataEntry
+      JOIN ServerDataValue AS VALUE_AVG ON ServerDataEntry.ID = VALUE_AVG.EntryID AND VALUE_AVG.Type = ? AND VALUE_AVG.Key = 'avg'
+      JOIN ServerDataValue AS VALUE_PEAK ON ServerDataEntry.ID = VALUE_PEAK.EntryID AND VALUE_PEAK.Type = ? AND VALUE_PEAK.Key = 'peak'
+      ${
+        select_max
+          ? "JOIN ServerDataValue AS VALUE_MAX ON ServerDataEntry.ID = VALUE_MAX.EntryID AND VALUE_MAX.Type = ? AND VALUE_MAX.Key = 'max'"
+          : ''
+      }
+      WHERE ServerDataEntry.ServerID = ?
+      AND ServerDataEntry.Timestamp BETWEEN ? AND ?;
+    `,
+      [...select_types, serverID, from.getTime(), to.getTime()]
+    );
+
+    const row = result.rows[0];
+    if (Object.keys(row).includes('max')) {
+      return {
+        avg: ((row['avg'] as number) / (row['max'] as number)) * 100,
+        peak: ((row['peak'] as number) / (row['max'] as number)) * 100
+      };
+    } else {
+      return {
+        avg: row['avg'] as number,
+        peak: row['peak'] as number
+      };
+    }
+  }
+
   private async getHealthCheckConfigs(serverID?: number, type = 'http') {
     const res = await this.stmt(
       `SELECT 

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

@@ -65,5 +65,36 @@ export class ServerAPIHandler extends WebHandler {
         next(err);
       }
     });
+
+    this.router.get('/:serverID/stats', async (req, res, next) => {
+      try {
+        const serverID = Number(req.params.serverID);
+
+        if (Number.isNaN(serverID)) {
+          throw new HttpStatusException(`Not a valid server id: ${req.params.serverID}`, 400);
+        }
+
+        const qStart = (req.query.start || '').toString();
+        const qEnd = (req.query.end || '').toString();
+        const qType = (req.query.type || '').toString();
+
+        if (!qStart || !qEnd || !qType) throw new HttpStatusException("QueryParams 'type', '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.queryServerStats(serverID, qType, start, end);
+        res.send({
+          start,
+          end,
+          data
+        } as QueryResponse<ReducedValuesPerc>);
+      } catch (err) {
+        next(err);
+      }
+    });
   }
 }