import { Component, ElementRef, HostListener, Input, OnInit, ViewChild } from '@angular/core'; import { faLocationDot, faEllipsis, faMagnifyingGlassPlus } from '@fortawesome/free-solid-svg-icons'; import { ChartData, ChartOptions, TooltipItem } from 'chart.js'; import * as dateFns from 'date-fns'; import { debounceTime, throttleTime, Subject } from 'rxjs'; import { BytePipe } from 'src/app/pipes/byte.pipe'; import { ServerApiService } from 'src/app/services/server-api.service'; 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-chart', templateUrl: './server-data-chart.component.html', styleUrls: ['./server-data-chart.component.scss'] }) export class ServerDataChartComponent implements OnInit { private zoomEvent$ = new Subject(); private dragEvent$ = new Subject(); @ViewChild('dragImage') dragImage!: ElementRef; @Input() parent?: string; @Input() server!: ServerConfig; @Input() type!: string; public start!: Date; public end!: Date; public data?: ChartData<'line', ServerData[], Date>; public options!: ChartOptions<'line'> & any; public fa = { locationDot: faLocationDot, ellipsis: faEllipsis, zoomPlus: faMagnifyingGlassPlus }; public zoomRanges = ['3M', '1M', '2W', '4D', '1D', '12H', '4H', '1H']; public selectedZoomRange = '4H'; constructor(private apiService: ServerApiService, private bytePipe: BytePipe) { this.defaults(); this.zoomEvent$.pipe(debounceTime(400)).subscribe({ next: this.updateData.bind(this) }); this.dragEvent$.pipe(throttleTime(80)).subscribe({ next: this.dragDebounced.bind(this) }); } ngOnInit(): void { this.updateData(); } private defaults() { this.end = new Date(); this.start = new Date(this.end.getTime() - 1000 * 60 * 60 * 4); } private updateOptions() { const diffHrs = (this.end.getTime() - this.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 }; this.options = { scales: { xAxis: { display: true, type: 'time', time: { ...timeFormat, displayFormats: { day: 'yyyy-MM-dd', hour: 'yyyy-MM-dd HH:mm', minute: 'HH:mm:ss' } }, min: this.start, max: this.end }, yAxis: { beginAtZero: true, type: 'linear', ticks: { callback: (val: string | number) => (typeof val === 'number' && this.type !== 'cpu' ? this.bytePipe.transform(val) : `${val} %`) }, max: this.type === 'cpu' ? 100 : undefined } } }; } private async updateData() { try { const data = await this.apiService.queryServerData( this.server.id, `${this.parent ?? this.type}${this.parent ? `:${this.type}` : ''}`, this.start, this.end ); 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: this.type !== 'cpu' ? (item: TooltipItem<'line'> & any) => this.bytePipe.transform(item.raw[key]) : (item: TooltipItem<'line'> & any) => `${(item.raw[key] as number).toFixed(2)} %` } } })) : [] }; this.updateOptions(); this.data = chartData; } catch (err) { console.error(err); } } zoom(ev: Event) { const event = ev as WheelEvent; if (event.shiftKey) { event.stopPropagation(); event.preventDefault(); event.cancelBubble = true; const xRel = event.offsetX / (event.target as HTMLCanvasElement).clientWidth; const nowMs = new Date().getTime(); const currentRangeMs = this.end.getTime() - this.start.getTime(); const cursorTimeMs = this.start.getTime() + currentRangeMs * xRel; const step = event.deltaY / 10; const newRangeMs = currentRangeMs * (1 + 1 / step); const newStartMs = Math.min(cursorTimeMs - newRangeMs * xRel, nowMs - newRangeMs); const newEndMs = newStartMs + newRangeMs; this.start = new Date(newStartMs); this.end = new Date(newEndMs); this.selectedZoomRange = ''; this.updateOptions(); this.zoomEvent$.next(); } } private dragStartParams = { offsetX: 0, rangeStart: 0, rangeEnd: 0 }; public zoomMode = false; @HostListener('window:keydown', ['$event']) hotKeydown(event: KeyboardEvent) { if (event.shiftKey) this.zoomMode = true; } @HostListener('window:keyup', ['$event']) hotKeyup(event: KeyboardEvent) { if (event.key.toLowerCase() === 'shift') this.zoomMode = false; } dragStart(ev: Event) { const event = ev as DragEvent; this.dragStartParams = { offsetX: event.offsetX, rangeStart: this.start.getTime(), rangeEnd: this.end.getTime() }; if (event.dataTransfer) { event.dataTransfer.setDragImage(this.dragImage.nativeElement, -10, -10); event.dataTransfer.effectAllowed = 'all'; } (event.target as HTMLCanvasElement).style.cursor = 'grabbing'; event.stopPropagation(); // event.preventDefault(); event.cancelBubble = true; } drag(ev: Event) { const event = ev as DragEvent; event.preventDefault(); event.stopPropagation(); event.cancelBubble = true; this.dragEvent$.next(event); } dragDebounced(event: DragEvent) { const nowMs = new Date().getTime(); const xDiffRel = (event.offsetX - this.dragStartParams.offsetX) / (event.target as HTMLCanvasElement).clientWidth; const currentRangeMs = this.dragStartParams.rangeEnd - this.dragStartParams.rangeStart; const xDiff = xDiffRel * currentRangeMs; const newStartMs = Math.min(this.dragStartParams.rangeStart - xDiff, nowMs - currentRangeMs); const newEndMs = newStartMs + currentRangeMs; if (this.start.getTime() !== newStartMs) { this.start = new Date(newStartMs); this.end = new Date(newEndMs); this.updateOptions(); } } dragEnd(ev: Event) { const event = ev as DragEvent; (event.target as HTMLCanvasElement).style.removeProperty('cursor'); this.updateOptions(); this.zoomEvent$.next(); } zoomButton(zoomRange: string) { this.selectedZoomRange = zoomRange; const m = /^(\d+)(\w)$/.exec(zoomRange); if (!m) return; switch (m[2]) { case 'H': this.start = dateFns.addHours(this.end, -1 * Number(m[1])); break; case 'D': this.start = dateFns.addDays(this.end, -1 * Number(m[1])); break; case 'W': this.start = dateFns.addWeeks(this.end, -1 * Number(m[1])); break; case 'M': this.start = dateFns.addMonths(this.end, -1 * Number(m[1])); break; } this.zoomEvent$.next(); } }