Browse Source

Angular: integrated chart.js; built up charts on ServerDataPage; emptied Home/Dashboard

Christian Kahlau 3 năm trước cách đây
mục cha
commit
44b261ce97

+ 37 - 0
common/util/object-utils.ts

@@ -0,0 +1,37 @@
+export function deepCopy<T>(obj: T, keys?: string[]): T {
+  // Handle Object
+  if (obj instanceof String) {
+    return (obj + '') as unknown as T;
+  } else if (obj instanceof Date) {
+    return new Date(obj.getTime()) as unknown as T;
+  } else if (obj instanceof Array) {
+    const copy = Array(obj.length);
+    for (let i = 0, len = obj.length; i < len; i++) {
+      copy[i] = deepCopy(obj[i]);
+    }
+    return copy as unknown as T;
+  } else if (obj instanceof Set) {
+    const copy = new Set();
+    obj.forEach(value => {
+      copy.add(deepCopy(value));
+    });
+    return copy as unknown as T;
+  } else if (obj instanceof Map) {
+    const copy = new Map();
+    obj.forEach((value, key) => {
+      copy.set(key, deepCopy(value));
+    });
+    return copy as unknown as T;
+  } else if (obj instanceof Object) {
+    const copy: { [key: string]: any } = {};
+    for (const attr in obj) {
+      if (typeof keys !== 'undefined' && !keys.includes(attr)) {
+        continue;
+      }
+      if (Object.getOwnPropertyNames(obj).includes(attr)) copy[attr] = deepCopy((obj as { [key: string]: any })[attr]);
+    }
+    return copy as unknown as T;
+  } else {
+    return obj;
+  }
+}

+ 4 - 0
ng/package.json

@@ -23,6 +23,10 @@
     "@fortawesome/free-brands-svg-icons": "^6.1.1",
     "@fortawesome/free-regular-svg-icons": "^6.1.1",
     "@fortawesome/free-solid-svg-icons": "^6.1.1",
+    "chart.js": "^3.6.0",
+    "chartjs-adapter-date-fns": "^2.0.0",
+    "date-fns": "^2.29.3",
+    "ng2-charts": "^4.0.1",
     "rxjs": "~7.5.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.11.4"

+ 6 - 4
ng/src/app/app.module.ts

@@ -1,19 +1,21 @@
 import { HttpClientModule } from '@angular/common/http';
 import { NgModule } from '@angular/core';
 import { BrowserModule } from '@angular/platform-browser';
+import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
+import { NgChartsModule } from 'ng2-charts';
 
 import { AppRoutingModule } from './app-routing.module';
 import { AppComponent } from './app.component';
 import { HeaderComponent } from './components/header/header.component';
 import { HomePageComponent } from './pages/home-page/home-page.component';
-import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
 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';
 
 @NgModule({
-  declarations: [AppComponent, HeaderComponent, HomePageComponent, FaByTypePipe, ServerDataPageComponent],
-  imports: [AppRoutingModule, BrowserModule, FontAwesomeModule, HttpClientModule],
-  providers: [],
+  declarations: [AppComponent, BytePipe, HeaderComponent, HomePageComponent, FaByTypePipe, ServerDataPageComponent],
+  imports: [AppRoutingModule, BrowserModule, FontAwesomeModule, HttpClientModule, NgChartsModule],
+  providers: [{ provide: BytePipe, multi: false }],
   bootstrap: [AppComponent]
 })
 export class AppModule {}

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

@@ -1,77 +1 @@
-<ng-container *ngIf="!!servers?.length; else nothing">
-  <ul>
-    <li *ngFor="let server of servers; index as t" class="list-unstyled">
-      <button
-        class="btn btn-primary dropdown-toggle"
-        type="button"
-        data-bs-toggle="collapse"
-        [attr.data-bs-target]="'#data-types-' + t"
-        aria-expanded="false">
-        {{ server.title }}
-      </button>
-      <ul *ngIf="server.types?.length" class="collapse" [id]="'data-types-' + t">
-        <li *ngFor="let type of server.types; index as i" class="list-unstyled">
-          <button
-            class="btn btn-primary dropdown-toggle"
-            type="button"
-            data-bs-toggle="collapse"
-            [attr.data-bs-target]="'#data-table-' + i"
-            aria-expanded="false">
-            {{ type.type }}
-          </button>
-          <ul *ngIf="type.subtypes?.length; else data" class="collapse" [id]="'data-table-' + i">
-            <li *ngFor="let sub of type.subtypes; index as j" class="list-unstyled">
-              <button
-                class="btn btn-primary dropdown-toggle"
-                type="button"
-                data-bs-toggle="collapse"
-                [attr.data-bs-target]="'#data-table-sub-' + j"
-                aria-expanded="false">
-                {{ sub.type }}
-              </button>
-              <div *ngIf="sub.data?.length" class="collapse" [id]="'data-table-sub-' + j">
-                <table class="table table-striped">
-                  <tbody>
-                    <tr>
-                      <th>Time</th>
-                      <th>∅ avg</th>
-                      <th>∧ peak</th>
-                      <th>Ω avail</th>
-                    </tr>
-                    <tr *ngFor="let dataset of sub.data">
-                      <td>{{ dataset.time | date: 'YYYY-MM-dd HH:mm:ss' }}</td>
-                      <td>{{ dataset.avg | number }}</td>
-                      <td>{{ dataset.peak | number }}</td>
-                      <td>{{ dataset.max | number }}</td>
-                    </tr>
-                  </tbody>
-                </table>
-              </div>
-            </li>
-          </ul>
-          <ng-template #data>
-            <div *ngIf="type.data?.length" class="collapse" [id]="'data-table-' + i">
-              <table class="table table-striped">
-                <tbody>
-                  <tr>
-                    <th>Time</th>
-                    <th>∅ avg</th>
-                    <th>∧ peak</th>
-                    <th *ngIf="type.data?.[0]?.max">Ω avail</th>
-                  </tr>
-                  <tr *ngFor="let dataset of type.data">
-                    <td>{{ dataset.time | date: 'YYYY-MM-dd HH:mm:ss' }}</td>
-                    <td>{{ dataset.avg | number }}</td>
-                    <td>{{ dataset.peak | number }}</td>
-                    <td *ngIf="dataset.max">{{ dataset.max | number }}</td>
-                  </tr>
-                </tbody>
-              </table>
-            </div>
-          </ng-template>
-        </li>
-      </ul>
-    </li>
-  </ul>
-</ng-container>
-<ng-template #nothing> Nothing to display </ng-template>
+<p>Dashboard works!</p>

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

@@ -8,32 +8,7 @@ import { ServerApiService } from '../../services/server-api.service';
   styleUrls: ['./home-page.component.scss']
 })
 export class HomePageComponent implements OnInit {
-  public servers?: Array<
-    Server & { types?: Array<ServerDataTypesConfig & { data?: ServerData[]; subtypes?: Array<ServerDataTypesConfig & { data?: ServerData[] }> }> }
-  >;
-
   constructor(private serverApi: ServerApiService) {}
 
-  async ngOnInit(): Promise<void> {
-    try {
-      const end = new Date();
-      const start = new Date(end.getTime() - 1000 * 60 * 60 * 4);
-      this.servers = await this.serverApi.getAllServerConfigs();
-      for (const server of this.servers) {
-        server.types = await this.serverApi.getServerDataTypes(server.id);
-
-        for (const dataType of server.types) {
-          if (dataType.subtypes) {
-            for (const sub of dataType.subtypes) {
-              sub.data = await this.serverApi.queryServerData(server.id, `${dataType.type}:${sub.type}`, start, end);
-            }
-          } else {
-            dataType.data = await this.serverApi.queryServerData(server.id, dataType.type, start, end);
-          }
-        }
-      }
-    } catch (err) {
-      console.error(err);
-    }
-  }
+  async ngOnInit(): Promise<void> {}
 }

+ 36 - 11
ng/src/app/pages/server-data-page/server-data-page.component.html

@@ -1,13 +1,38 @@
-<p>server-data-page works!</p>
-<p *ngIf="server">{{ server.title }}</p>
+<h3 *ngIf="server">{{ server.title }}</h3>
 
-<div *ngIf="types?.length">
-  <div *ngFor="let type of types">
-    <div *ngIf="type.subtypes?.length; else single">
-      <div *ngFor="let sub of type.subtypes">{{ type.type }}:{{ sub.type }} = {{ sub.data?.length || 0 }} Entries</div>
-    </div>
-    <ng-template #single>
-      <div>{{ type.type }} = {{ type.data?.length || 0 }} Entries</div>
+<ng-container *ngIf="types?.length">
+  <ng-container *ngFor="let type of types">
+    <ng-container *ngIf="type.subtypes?.length; else singleDataType">
+      <div *ngFor="let sub of type.subtypes" class="card mb-2">
+        <div class="card-header d-flex flex-row">
+          <span class="flex-fill">
+            <fa-icon [icon]="type.type | faType" class="pe-2"></fa-icon>
+            <span class="text-uppercase">{{ type.type }}</span
+            >: {{ sub.type }}
+          </span>
+          <span>
+            <span class="badge bd-blue-100">{{ sub.start | date: 'YYYY-MM-dd HH:mm:ss' }}</span> -
+            <span class="badge bd-blue-100">{{ sub.end | date: 'YYYY-MM-dd HH:mm:ss' }}</span>
+          </span>
+        </div>
+        <div class="card-body">
+          <canvas baseChart type="line" [data]="sub.data" [options]="sub.options"></canvas>
+        </div>
+      </div>
+    </ng-container>
+    <ng-template #singleDataType>
+      <div class="card mb-2">
+        <div class="card-header text-uppercase d-flex flex-row">
+          <span class="flex-fill"> <fa-icon [icon]="type.type | faType" class="pe-2"></fa-icon>{{ type.type }}</span>
+          <span>
+            <span class="badge bd-blue-100">{{ type.start | date: 'YYYY-MM-dd HH:mm:ss' }}</span> -
+            <span class="badge bd-blue-100">{{ type.end | date: 'YYYY-MM-dd HH:mm:ss' }}</span>
+          </span>
+        </div>
+        <div class="card-body">
+          <canvas baseChart type="line" [data]="type.data" [options]="type.options"></canvas>
+        </div>
+      </div>
     </ng-template>
-  </div>
-</div>
+  </ng-container>
+</ng-container>

+ 155 - 18
ng/src/app/pages/server-data-page/server-data-page.component.ts

@@ -1,9 +1,35 @@
 import { Component, OnDestroy, OnInit } from '@angular/core';
 import { ActivationEnd, Router } from '@angular/router';
+import { ChartData, ChartOptions, TooltipItem } from 'chart.js';
+import { _DeepPartialObject } from 'chart.js/types/utils';
+import 'chartjs-adapter-date-fns';
 import { filter, map, Subscription } from 'rxjs';
 
+import { deepCopy } from '../../../../../common/util/object-utils';
+
+import { BytePipe } from 'src/app/pipes/byte.pipe';
 import { ServerApiService } from 'src/app/services/server-api.service';
 
+type ServerDataGraphOptions = ServerDataTypesConfig & {
+  subtypes?: Array<ServerDataGraphOptions>;
+  data?: ChartData<'line', ServerData[], Date>;
+  options: ChartOptions<'line'>;
+  start: Date;
+  end: Date;
+};
+type ChartConfigTimeUnit = false | 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' | undefined;
+
+const typeDotColors: { [k: string]: string } = {
+  avg: '#cc9a06',
+  peak: '#005299',
+  max: '#941320'
+};
+
+const typeBgColors: { [k: string]: string } = {
+  avg: '#ffe69c',
+  peak: '#cce7ff',
+  max: '#e3a3a9'
+};
 @Component({
   selector: 'app-server-data-page',
   templateUrl: './server-data-page.component.html',
@@ -13,15 +39,18 @@ export class ServerDataPageComponent implements OnInit, OnDestroy {
   private subscriptions: Subscription[] = [];
 
   public server?: ServerConfig;
-  public types?: ServerDataTypeWithData[];
+  public types?: ServerDataGraphOptions[];
 
-  constructor(private apiService: ServerApiService, router: Router) {
+  constructor(private apiService: ServerApiService, router: Router, private bytePipe: BytePipe) {
     router.events.subscribe({
       next: event => {
         if (event instanceof ActivationEnd) {
           this.clearSubscriptions();
 
           const serverID = Number(event.snapshot.params['id']);
+          if (serverID !== this.server?.id) {
+            this.clearPageModel();
+          }
           this.subscriptions.push(
             apiService.serverConfigs$.pipe(map(servers => servers.find(s => s.id === serverID))).subscribe(this.onServerConfig.bind(this)),
             apiService.serverDataTypes$
@@ -37,34 +66,137 @@ export class ServerDataPageComponent implements OnInit, OnDestroy {
   ngOnInit(): void {}
 
   onServerConfig(server?: ServerConfig) {
-    if (server) {
-      this.server = server;
-    } else {
-      this.server = undefined;
-    }
+    this.server = server;
   }
 
-  onServerDataTypes(types: ServerDataTypeWithData[]) {
+  onServerDataTypes(types: ServerDataTypesConfig[]) {
     if (this.server) {
-      this.types = types.map(type => {
-        if (!type.subtypes) this.updateData(this.server as ServerConfig, type);
-        else {
-          type.subtypes.forEach(sub => {
+      const end = new Date();
+      const start = new Date(end.getTime() - 1000 * 60 * 60 * 4);
+      this.types = deepCopy(types).map((origType: ServerDataTypeWithData) => {
+        const type = origType as ServerDataGraphOptions;
+        const oldType = this.types?.find(t => type.type === t.type);
+        if (!type.subtypes) {
+          if (oldType && oldType.data) {
+            type.data = oldType.data;
+            return type;
+          }
+          type.start = start;
+          type.end = end;
+          this.updateData(this.server as ServerConfig, type);
+        } else {
+          type.subtypes.forEach((origSub: ServerDataTypeWithData) => {
+            const sub = origSub as ServerDataGraphOptions;
+            if (oldType) {
+              const oldSub = oldType.subtypes?.find(s => sub.type === s.type) as ServerDataGraphOptions;
+              if (oldSub && oldSub.data) {
+                sub.data = oldSub.data;
+                return;
+              }
+            }
+            sub.start = start;
+            sub.end = end;
             this.updateData(this.server as ServerConfig, type, sub);
           });
         }
-        return type;
+        return type as ServerDataGraphOptions;
       });
     }
   }
 
-  async updateData(server: ServerConfig, type: ServerDataTypeWithData, subType?: ServerDataTypeWithData) {
+  async updateData(server: ServerConfig, type: ServerDataGraphOptions, subType?: ServerDataGraphOptions) {
     try {
-      const end = new Date();
-      const start = new Date(end.getTime() - 1000 * 60 * 60 * 4);
+      const end = subType ? subType.end : type.end;
+      const start = subType ? subType.start : type.start;
+
+      const diffHrs = (end.getTime() - start.getTime()) / 1000 / 60 / 60;
+      const timeFormat: {
+        tooltipFormat: string;
+        unit: ChartConfigTimeUnit;
+        stepSize: number;
+      } =
+        diffHrs > 24 * 4
+          ? {
+              tooltipFormat: 'yyyy-MM-dd HH:mm',
+              unit: 'day',
+              stepSize: 1
+            }
+          : diffHrs > 24 * 2
+          ? {
+              tooltipFormat: 'yyyy-MM-dd HH:mm',
+              unit: 'hour',
+              stepSize: 6
+            }
+          : diffHrs > 12
+          ? {
+              tooltipFormat: 'HH:mm:ss',
+              unit: 'hour',
+              stepSize: 1
+            }
+          : {
+              tooltipFormat: 'HH:mm:ss',
+              unit: 'minute',
+              stepSize: 30
+            };
+
       const data = await this.apiService.queryServerData(server.id, `${type.type}${subType ? `:${subType.type}` : ''}`, start, end);
-      if (subType) subType.data = data;
-      else type.data = data;
+      const chartData: ChartData<'line', ServerData[], Date> = {
+        labels: data.map(d => d.time),
+        datasets: data.length
+          ? Object.keys(data[0])
+              .filter(k => k !== 'time')
+              .map(key => ({
+                label: key,
+                data,
+                parsing: { yAxisKey: key, xAxisKey: 'time' },
+                fill: key !== 'max',
+                tension: 0.3,
+                backgroundColor: typeBgColors[key],
+                pointBorderColor: typeDotColors[key],
+                borderColor: typeDotColors[key],
+                pointBackgroundColor: typeBgColors[key],
+                pointRadius: key === 'max' ? 0 : undefined,
+                tooltip: {
+                  callbacks: {
+                    label:
+                      type.type !== 'cpu'
+                        ? (item: TooltipItem<'line'> & any) => this.bytePipe.transform(item.raw[key])
+                        : (item: TooltipItem<'line'> & any) => `${(item.raw[key] as number).toFixed(2)} %`
+                  }
+                }
+              }))
+          : []
+      };
+      const options: ChartOptions<'line'> & any = {
+        scales: {
+          xAxis: {
+            display: true,
+            type: 'time',
+            time: {
+              ...timeFormat,
+              displayFormats: {
+                day: 'yyyy-MM-dd',
+                hour: 'yyyy-MM-dd HH:mm',
+                minute: 'HH:mm:ss'
+              }
+            }
+          },
+          yAxis: {
+            beginAtZero: true,
+            type: 'linear',
+            ticks: {
+              callback: (val: string | number) => (typeof val === 'number' && type.type !== 'cpu' ? this.bytePipe.transform(val) : `${val} %`)
+            }
+          }
+        }
+      };
+      if (subType) {
+        subType.data = chartData;
+        subType.options = options;
+      } else {
+        type.data = chartData;
+        type.options = options;
+      }
     } catch (err) {
       console.error(err);
     }
@@ -78,4 +210,9 @@ export class ServerDataPageComponent implements OnInit, OnDestroy {
     this.subscriptions.forEach(s => s.unsubscribe());
     this.subscriptions = [];
   }
+
+  clearPageModel() {
+    this.server = undefined;
+    this.types = undefined;
+  }
 }

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

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

+ 20 - 0
ng/src/app/pipes/byte.pipe.ts

@@ -0,0 +1,20 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'byte'
+})
+export class BytePipe implements PipeTransform {
+  private suffixes = ['', 'K', 'M', 'G', 'T', 'P'];
+
+  transform(value?: number, decimals = 2, base = 1024, baseUnit = 'B', ...args: unknown[]): string {
+    if (!value) return '0 B';
+
+    let idx = 0;
+    while (value > base) {
+      idx++;
+      value /= base;
+    }
+
+    return `${value.toFixed(decimals)} ${this.suffixes[idx]}${baseUnit}`;
+  }
+}

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

@@ -4,8 +4,6 @@ import { firstValueFrom, map, ReplaySubject } from 'rxjs';
 import { environment } from 'src/environments/environment';
 import { IndexedReplaySubject } from '../lib/indexed-subject.class';
 
-type ServerDataTypesSubject = ReplaySubject<ServerDataTypeWithData[]>;
-
 @Injectable({
   providedIn: 'root'
 })
@@ -15,7 +13,7 @@ export class ServerApiService {
   private servers: ServerConfig[] = [];
 
   public serverConfigs$ = new ReplaySubject<ServerConfig[]>(1);
-  public serverDataTypes$ = new IndexedReplaySubject<number, ServerDataTypeWithData[]>();
+  public serverDataTypes$ = new IndexedReplaySubject<number, ServerDataTypesConfig[]>();
 
   public async getAllServerConfigs() {
     this.servers = await firstValueFrom(this.http.get<Server[]>(environment.apiBaseUrl + 'server'));

+ 1 - 1
ng/src/app/types/server-config.d.ts

@@ -4,5 +4,5 @@ type ServerConfig = Server & {
 
 type ServerDataTypeWithData = ServerDataTypesConfig & {
   data?: ServerData[];
-  subtypes?: Array<ServerDataTypesConfig & { data?: ServerData[] }>;
+  subtypes?: ServerDataTypeWithData[];
 };