server-data-chart.component.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { Component, ElementRef, HostListener, Input, OnInit, ViewChild } from '@angular/core';
  2. import { faLocationDot, faEllipsis, faMagnifyingGlassPlus } from '@fortawesome/free-solid-svg-icons';
  3. import { ChartData, ChartOptions, TooltipItem } from 'chart.js';
  4. import * as dateFns from 'date-fns';
  5. import { debounceTime, throttleTime, Subject } from 'rxjs';
  6. import { BytePipe } from 'src/app/pipes/byte.pipe';
  7. import { ServerApiService } from 'src/app/services/server-api.service';
  8. type ChartConfigTimeUnit = false | 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year' | undefined;
  9. const typeDotColors: { [k: string]: string } = {
  10. avg: '#cc9a06',
  11. peak: '#005299',
  12. max: '#941320'
  13. };
  14. const typeBgColors: { [k: string]: string } = {
  15. avg: '#ffe69c',
  16. peak: '#cce7ff',
  17. max: '#e3a3a9'
  18. };
  19. @Component({
  20. selector: 'app-server-data-chart',
  21. templateUrl: './server-data-chart.component.html',
  22. styleUrls: ['./server-data-chart.component.scss']
  23. })
  24. export class ServerDataChartComponent implements OnInit {
  25. private zoomEvent$ = new Subject<void>();
  26. private dragEvent$ = new Subject<DragEvent>();
  27. @ViewChild('dragImage') dragImage!: ElementRef<HTMLImageElement>;
  28. @Input() parent?: string;
  29. @Input() server!: ServerConfig;
  30. @Input() type!: string;
  31. public start!: Date;
  32. public end!: Date;
  33. public data?: ChartData<'line', ServerData[], Date>;
  34. public options!: ChartOptions<'line'> & any;
  35. public fa = { locationDot: faLocationDot, ellipsis: faEllipsis, zoomPlus: faMagnifyingGlassPlus };
  36. public zoomRanges = ['3M', '1M', '2W', '4D', '1D', '12H', '4H', '1H'];
  37. public selectedZoomRange = '4H';
  38. constructor(private apiService: ServerApiService, private bytePipe: BytePipe) {
  39. this.defaults();
  40. this.zoomEvent$.pipe(debounceTime(400)).subscribe({ next: this.updateData.bind(this) });
  41. this.dragEvent$.pipe(throttleTime(80)).subscribe({ next: this.dragDebounced.bind(this) });
  42. }
  43. ngOnInit(): void {
  44. this.updateData();
  45. }
  46. private defaults() {
  47. this.end = new Date();
  48. this.start = new Date(this.end.getTime() - 1000 * 60 * 60 * 4);
  49. }
  50. private updateOptions() {
  51. const diffHrs = (this.end.getTime() - this.start.getTime()) / 1000 / 60 / 60;
  52. const timeFormat: {
  53. tooltipFormat: string;
  54. unit: ChartConfigTimeUnit;
  55. stepSize: number;
  56. } =
  57. diffHrs > 24 * 4
  58. ? {
  59. tooltipFormat: 'yyyy-MM-dd HH:mm',
  60. unit: 'day',
  61. stepSize: 1
  62. }
  63. : diffHrs > 24 * 2
  64. ? {
  65. tooltipFormat: 'yyyy-MM-dd HH:mm',
  66. unit: 'hour',
  67. stepSize: 6
  68. }
  69. : diffHrs > 12
  70. ? {
  71. tooltipFormat: 'HH:mm:ss',
  72. unit: 'hour',
  73. stepSize: 1
  74. }
  75. : {
  76. tooltipFormat: 'HH:mm:ss',
  77. unit: 'minute',
  78. stepSize: 30
  79. };
  80. this.options = {
  81. scales: {
  82. xAxis: {
  83. display: true,
  84. type: 'time',
  85. time: {
  86. ...timeFormat,
  87. displayFormats: {
  88. day: 'yyyy-MM-dd',
  89. hour: 'yyyy-MM-dd HH:mm',
  90. minute: 'HH:mm:ss'
  91. }
  92. },
  93. min: this.start,
  94. max: this.end
  95. },
  96. yAxis: {
  97. beginAtZero: true,
  98. type: 'linear',
  99. ticks: {
  100. callback: (val: string | number) => (typeof val === 'number' && this.type !== 'cpu' ? this.bytePipe.transform(val) : `${val} %`)
  101. },
  102. max: this.type === 'cpu' ? 100 : undefined
  103. }
  104. }
  105. };
  106. }
  107. private async updateData() {
  108. try {
  109. const data = await this.apiService.queryServerData(
  110. this.server.id,
  111. `${this.parent ?? this.type}${this.parent ? `:${this.type}` : ''}`,
  112. this.start,
  113. this.end
  114. );
  115. const chartData: ChartData<'line', ServerData[], Date> = {
  116. labels: data.map(d => d.time),
  117. datasets: data.length
  118. ? Object.keys(data[0])
  119. .filter(k => k !== 'time')
  120. .map(key => ({
  121. label: key,
  122. data,
  123. parsing: { yAxisKey: key, xAxisKey: 'time' },
  124. fill: key !== 'max',
  125. tension: 0.3,
  126. backgroundColor: typeBgColors[key],
  127. pointBorderColor: typeDotColors[key],
  128. borderColor: typeDotColors[key],
  129. pointBackgroundColor: typeBgColors[key],
  130. pointRadius: key === 'max' ? 0 : undefined,
  131. tooltip: {
  132. callbacks: {
  133. label:
  134. this.type !== 'cpu'
  135. ? (item: TooltipItem<'line'> & any) => this.bytePipe.transform(item.raw[key])
  136. : (item: TooltipItem<'line'> & any) => `${(item.raw[key] as number).toFixed(2)} %`
  137. }
  138. }
  139. }))
  140. : []
  141. };
  142. this.updateOptions();
  143. this.data = chartData;
  144. } catch (err) {
  145. console.error(err);
  146. }
  147. }
  148. zoom(ev: Event) {
  149. const event = ev as WheelEvent;
  150. if (event.shiftKey) {
  151. event.stopPropagation();
  152. event.preventDefault();
  153. event.cancelBubble = true;
  154. const xRel = event.offsetX / (event.target as HTMLCanvasElement).clientWidth;
  155. const nowMs = new Date().getTime();
  156. const currentRangeMs = this.end.getTime() - this.start.getTime();
  157. const cursorTimeMs = this.start.getTime() + currentRangeMs * xRel;
  158. const step = event.deltaY / 10;
  159. const newRangeMs = currentRangeMs * (1 + 1 / step);
  160. const newStartMs = Math.min(cursorTimeMs - newRangeMs * xRel, nowMs - newRangeMs);
  161. const newEndMs = newStartMs + newRangeMs;
  162. this.start = new Date(newStartMs);
  163. this.end = new Date(newEndMs);
  164. this.selectedZoomRange = '';
  165. this.updateOptions();
  166. this.zoomEvent$.next();
  167. }
  168. }
  169. private dragStartParams = {
  170. offsetX: 0,
  171. rangeStart: 0,
  172. rangeEnd: 0
  173. };
  174. public zoomMode = false;
  175. @HostListener('window:keydown', ['$event'])
  176. hotKeydown(event: KeyboardEvent) {
  177. if (event.shiftKey) this.zoomMode = true;
  178. }
  179. @HostListener('window:keyup', ['$event'])
  180. hotKeyup(event: KeyboardEvent) {
  181. if (event.key.toLowerCase() === 'shift') this.zoomMode = false;
  182. }
  183. dragStart(ev: Event) {
  184. const event = ev as DragEvent;
  185. this.dragStartParams = {
  186. offsetX: event.offsetX,
  187. rangeStart: this.start.getTime(),
  188. rangeEnd: this.end.getTime()
  189. };
  190. if (event.dataTransfer) {
  191. event.dataTransfer.setDragImage(this.dragImage.nativeElement, -10, -10);
  192. event.dataTransfer.effectAllowed = 'all';
  193. }
  194. (event.target as HTMLCanvasElement).style.cursor = 'grabbing';
  195. event.stopPropagation();
  196. // event.preventDefault();
  197. event.cancelBubble = true;
  198. }
  199. drag(ev: Event) {
  200. const event = ev as DragEvent;
  201. event.preventDefault();
  202. event.stopPropagation();
  203. event.cancelBubble = true;
  204. this.dragEvent$.next(event);
  205. }
  206. dragDebounced(event: DragEvent) {
  207. const nowMs = new Date().getTime();
  208. const xDiffRel = (event.offsetX - this.dragStartParams.offsetX) / (event.target as HTMLCanvasElement).clientWidth;
  209. const currentRangeMs = this.dragStartParams.rangeEnd - this.dragStartParams.rangeStart;
  210. const xDiff = xDiffRel * currentRangeMs;
  211. const newStartMs = Math.min(this.dragStartParams.rangeStart - xDiff, nowMs - currentRangeMs);
  212. const newEndMs = newStartMs + currentRangeMs;
  213. if (this.start.getTime() !== newStartMs) {
  214. this.start = new Date(newStartMs);
  215. this.end = new Date(newEndMs);
  216. this.updateOptions();
  217. }
  218. }
  219. dragEnd(ev: Event) {
  220. const event = ev as DragEvent;
  221. (event.target as HTMLCanvasElement).style.removeProperty('cursor');
  222. this.updateOptions();
  223. this.zoomEvent$.next();
  224. }
  225. zoomButton(zoomRange: string) {
  226. this.selectedZoomRange = zoomRange;
  227. const m = /^(\d+)(\w)$/.exec(zoomRange);
  228. if (!m) return;
  229. switch (m[2]) {
  230. case 'H':
  231. this.start = dateFns.addHours(this.end, -1 * Number(m[1]));
  232. break;
  233. case 'D':
  234. this.start = dateFns.addDays(this.end, -1 * Number(m[1]));
  235. break;
  236. case 'W':
  237. this.start = dateFns.addWeeks(this.end, -1 * Number(m[1]));
  238. break;
  239. case 'M':
  240. this.start = dateFns.addMonths(this.end, -1 * Number(m[1]));
  241. break;
  242. }
  243. this.zoomEvent$.next();
  244. }
  245. }