From 55ad7c8fc83b61ad43227e66c26d6fdd92f234f6 Mon Sep 17 00:00:00 2001 From: mutatrum Date: Fri, 7 Aug 2026 10:09:15 +0200 Subject: [PATCH 1/2] Dashboard performance improvements * Scoped CSS Transitions * Angular Zone Isolation for Chart.js * Add HeatmapLightnessPipe for memoization * Flash indicator timeouts runs outside Angular * Optimized Date Calculations * Bulk Array Pruning --- main/http_server/axe-os/src/app/app.module.ts | 2 + .../components/chart/app-chart.component.ts | 38 +++--- .../app/components/home/home.component.html | 18 +-- .../components/home/home.component.spec.ts | 4 +- .../src/app/components/home/home.component.ts | 121 +++++++++++------- .../axe-os/src/app/pipes/date-ago.pipe.ts | 9 +- .../app/pipes/heatmap-lightness.pipe.spec.ts | 24 ++++ .../src/app/pipes/heatmap-lightness.pipe.ts | 21 +++ .../src/app/services/live-data.service.ts | 2 +- 9 files changed, 162 insertions(+), 77 deletions(-) create mode 100644 main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.spec.ts create mode 100644 main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.ts diff --git a/main/http_server/axe-os/src/app/app.module.ts b/main/http_server/axe-os/src/app/app.module.ts index 3af3754720..4ec4b99255 100644 --- a/main/http_server/axe-os/src/app/app.module.ts +++ b/main/http_server/axe-os/src/app/app.module.ts @@ -43,6 +43,7 @@ import { HashSuffixPipe } from './pipes/hash-suffix.pipe'; import { DiffSuffixPipe } from './pipes/diff-suffix.pipe'; import { AddressPipe } from './pipes/address.pipe'; import { SatsPipe } from './pipes/sats.pipe'; +import { HeatmapLightnessPipe } from './pipes/heatmap-lightness.pipe'; import { DialogService, DialogListComponent } from './services/dialog.service'; const components = [ @@ -96,6 +97,7 @@ const components = [ DiffSuffixPipe, AddressPipe, SatsPipe, + HeatmapLightnessPipe, ], providers: [ { provide: LocationStrategy, useClass: HashLocationStrategy }, diff --git a/main/http_server/axe-os/src/app/components/chart/app-chart.component.ts b/main/http_server/axe-os/src/app/components/chart/app-chart.component.ts index 3d73aad0dc..5455d8af1e 100644 --- a/main/http_server/axe-os/src/app/components/chart/app-chart.component.ts +++ b/main/http_server/axe-os/src/app/components/chart/app-chart.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, Input, OnChanges, OnDestroy, SimpleChanges, ViewChild } from '@angular/core'; +import { Component, ElementRef, Input, NgZone, OnChanges, OnDestroy, SimpleChanges, ViewChild } from '@angular/core'; import { Chart, registerables } from 'chart.js'; Chart.register(...registerables); @@ -25,6 +25,8 @@ export class AppChartComponent implements OnChanges, OnDestroy { public chart: Chart | null = null; + constructor(private ngZone: NgZone) {} + ngOnChanges(changes: SimpleChanges) { if (changes['data'] || changes['options'] || changes['type']) { this.updateChart(); @@ -37,23 +39,27 @@ export class AppChartComponent implements OnChanges, OnDestroy { private destroyChart() { if (this.chart) { - this.chart.destroy(); - this.chart = null; + this.ngZone.runOutsideAngular(() => { + this.chart?.destroy(); + this.chart = null; + }); } } private updateChart() { if (!this.canvas) return; - if (this.chart) { - this.chart.data = this.data; - if (this.options) { - this.chart.options = this.options; + this.ngZone.runOutsideAngular(() => { + if (this.chart) { + this.chart.data = this.data; + if (this.options) { + this.chart.options = this.options; + } + this.chart.update(); + } else { + this.initChart(); } - this.chart.update(); - } else { - this.initChart(); - } + }); } private initChart() { @@ -61,10 +67,12 @@ export class AppChartComponent implements OnChanges, OnDestroy { const ctx = this.canvas.nativeElement.getContext('2d'); if (!ctx) return; - this.chart = new Chart(ctx, { - type: this.type as any, - data: this.data, - options: this.options + this.ngZone.runOutsideAngular(() => { + this.chart = new Chart(ctx, { + type: this.type as any, + data: this.data, + options: this.options + }); }); } diff --git a/main/http_server/axe-os/src/app/components/home/home.component.html b/main/http_server/axe-os/src/app/components/home/home.component.html index 1a111a45f1..82918b88ea 100644 --- a/main/http_server/axe-os/src/app/components/home/home.component.html +++ b/main/http_server/axe-os/src/app/components/home/home.component.html @@ -233,7 +233,7 @@
@let powerProgress = ((info.power / info.maxPower) * 100);
-
+
@@ -244,7 +244,7 @@
@let voltageProgress = ((info.voltage / (info.nominalVoltage + .5)) * 100);
-
+
@@ -268,7 +268,7 @@
@let freqProgress = ((info.actualFrequency / maxFrequency) * 100);
-
+
@@ -278,7 +278,7 @@
@let coreVoltProgress = ((info.coreVoltageActual / 1.8) * 100);
-
+
@@ -307,7 +307,7 @@
@let tempProgress = ((info.temp / maxTemp) * 100);
-
+
@@ -324,7 +324,7 @@
@let temp2Progress = ((info.temp2 / maxTemp) * 100);
-
+
@@ -338,7 +338,7 @@
@let vrTempProgress = ((info.vrTemp / 120) * 100);
-
+
@@ -370,7 +370,7 @@

Fan

@let fanProgress = info.fanspeed;
-
+
@@ -581,7 +581,7 @@

Hashrate {{ i + 1 }} {{ domain | hashSuffix }} diff --git a/main/http_server/axe-os/src/app/components/home/home.component.spec.ts b/main/http_server/axe-os/src/app/components/home/home.component.spec.ts index 4335899bfb..4fb19666e7 100644 --- a/main/http_server/axe-os/src/app/components/home/home.component.spec.ts +++ b/main/http_server/axe-os/src/app/components/home/home.component.spec.ts @@ -18,6 +18,7 @@ import { DateAgoPipe } from 'src/app/pipes/date-ago.pipe'; import { AddressPipe } from 'src/app/pipes/address.pipe'; import { SatsPipe } from 'src/app/pipes/sats.pipe'; import { ByteSuffixPipe } from 'src/app/pipes/byte-suffix.pipe'; +import { HeatmapLightnessPipe } from 'src/app/pipes/heatmap-lightness.pipe'; import { TooltipTextIconComponent } from 'src/app/components/tooltip-text-icon/tooltip-text-icon.component'; import { TooltipIconComponent } from 'src/app/components/tooltip-icon/tooltip-icon.component'; @@ -166,7 +167,8 @@ describe('HomeComponent', () => { DateAgoPipe, AddressPipe, SatsPipe, - ByteSuffixPipe + ByteSuffixPipe, + HeatmapLightnessPipe ], providers: [ provideRouter([]), diff --git a/main/http_server/axe-os/src/app/components/home/home.component.ts b/main/http_server/axe-os/src/app/components/home/home.component.ts index d7c34d1859..12fb80121b 100644 --- a/main/http_server/axe-os/src/app/components/home/home.component.ts +++ b/main/http_server/axe-os/src/app/components/home/home.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ViewChild, Input, OnDestroy, ElementRef, HostListener, effect } from '@angular/core'; +import { Component, OnInit, ViewChild, Input, OnDestroy, ElementRef, HostListener, effect, NgZone, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { map, Observable, shareReplay, Subscription, switchMap, tap, first, Subject, takeUntil, BehaviorSubject, filter, combineLatest } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; import { getHttpErrorMessage } from 'src/app/utils/error-handler'; @@ -74,6 +74,7 @@ const WIDGET_DEFAULTS: WidgetDef[] = [ selector: 'app-home', templateUrl: './home.component.html', styleUrls: ['./home.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) export class HomeComponent implements OnInit, OnDestroy { @@ -221,7 +222,9 @@ export class HomeComponent implements OnInit, OnDestroy { private shareRejectReasonsService: ShareRejectionExplanationService, private storageService: LocalStorageService, private dashboardEditService: DashboardEditService, - public layoutService: LayoutService + public layoutService: LayoutService, + private ngZone: NgZone, + private cd: ChangeDetectorRef ) { this.initializeChart(); @@ -285,7 +288,9 @@ export class HomeComponent implements OnInit, OnDestroy { this.loadPreviousData(); }) - this.staleCheckInterval = setInterval(() => this.checkStaleData(), 1000); + this.ngZone.runOutsideAngular(() => { + this.staleCheckInterval = setInterval(() => this.checkStaleData(), 1000); + }); this.loadPreviousData(); } @@ -481,7 +486,9 @@ export class HomeComponent implements OnInit, OnDestroy { const durationSeconds = Math.floor(elapsedMs / 1000); const current = this.systemInfoError$.value; if (current.duration !== durationSeconds) { - this.systemInfoError$.next({ duration: durationSeconds, startTime: this.lastMessageTime }); + this.ngZone.run(() => { + this.systemInfoError$.next({ duration: durationSeconds, startTime: this.lastMessageTime }); + }); } } } @@ -926,7 +933,12 @@ export class HomeComponent implements OnInit, OnDestroy { if (this.lastSharesAcceptedCount !== -1 && currentSharesAccepted > this.lastSharesAcceptedCount) { this.flashShareAccepted = true; clearTimeout(this.shareAcceptedTimeout); - this.shareAcceptedTimeout = setTimeout(() => this.flashShareAccepted = false, 500); + this.ngZone.runOutsideAngular(() => { + this.shareAcceptedTimeout = setTimeout(() => { + this.flashShareAccepted = false; + this.cd.markForCheck(); + }, 500); + }); } this.lastSharesAcceptedCount = currentSharesAccepted; @@ -934,7 +946,12 @@ export class HomeComponent implements OnInit, OnDestroy { if (this.lastSharesRejectedCount !== -1 && currentSharesRejected > this.lastSharesRejectedCount) { this.flashShareRejected = true; clearTimeout(this.shareRejectedTimeout); - this.shareRejectedTimeout = setTimeout(() => this.flashShareRejected = false, 500); + this.ngZone.runOutsideAngular(() => { + this.shareRejectedTimeout = setTimeout(() => { + this.flashShareRejected = false; + this.cd.markForCheck(); + }, 500); + }); } this.lastSharesRejectedCount = currentSharesRejected; @@ -942,9 +959,15 @@ export class HomeComponent implements OnInit, OnDestroy { if (this.lastWorkReceived !== -1 && currentWorkReceived > this.lastWorkReceived) { this.flashWorkReceived = true; clearTimeout(this.workReceivedTimeout); - this.workReceivedTimeout = setTimeout(() => this.flashWorkReceived = false, 500); + this.ngZone.runOutsideAngular(() => { + this.workReceivedTimeout = setTimeout(() => { + this.flashWorkReceived = false; + this.cd.markForCheck(); + }, 500); + }); } this.lastWorkReceived = currentWorkReceived; + this.cd.markForCheck(); }), map(info => { const formatted = { ...info }; @@ -967,6 +990,7 @@ export class HomeComponent implements OnInit, OnDestroy { .subscribe(([info, systemInfoError]) => { this.handleSystemMessages(info, systemInfoError); this.setTitle(info, systemInfoError); + this.cd.markForCheck(); }); this.info$.pipe(first(), takeUntil(this.destroy$)).subscribe(() => { @@ -1234,54 +1258,55 @@ export class HomeComponent implements OnInit, OnDestroy { const statsFrequencyMs = (statsFrequency || 30) * 1000; const windowDurationMs = limit * statsFrequencyMs; + const currentSpan = this.dataLabel[this.dataLabel.length - 1] - this.dataLabel[0]; + if (currentSpan >= windowDurationMs) { + const excess = this.dataLabel.length - limit; + if (excess > 0) { + this.dataLabel.splice(0, excess); + this.hashrateData.splice(0, excess); + this.powerData.splice(0, excess); + this.chartY1Data.splice(0, excess); + this.chartY2Data.splice(0, excess); + } + } + while (this.dataLabel.length > limit) { - const currentSpan = this.dataLabel[this.dataLabel.length - 1] - this.dataLabel[0]; - - if (currentSpan >= windowDurationMs) { - // Option A: Chart is at max capacity in time. Prune oldest to slide the window. - this.dataLabel.shift(); - this.hashrateData.shift(); - this.powerData.shift(); - this.chartY1Data.shift(); - this.chartY2Data.shift(); - } else { - // Option B: Chart is crowded. Binary search for the densest region. - // We initialize search range from index 1 to length - 2 to protect the oldest point (index 0) - // and newest point (index length - 1) from being deleted, preserving chart boundaries. - let low = 1; - let high = this.dataLabel.length - 2; - while (high - low > 1) { - const midTime = (this.dataLabel[low] + this.dataLabel[high]) / 2; - - let split = low; - for (let i = low; i <= high; i++) { - if (this.dataLabel[i] >= midTime) { - split = i; - break; - } + // Option B: Chart is crowded. Binary search for the densest region. + // We initialize search range from index 1 to length - 2 to protect the oldest point (index 0) + // and newest point (index length - 1) from being deleted, preserving chart boundaries. + let low = 1; + let high = this.dataLabel.length - 2; + while (high - low > 1) { + const midTime = (this.dataLabel[low] + this.dataLabel[high]) / 2; + + let split = low; + for (let i = low; i <= high; i++) { + if (this.dataLabel[i] >= midTime) { + split = i; + break; } + } - // Ensure we make progress even if multiple points have the same timestamp - if (split === low) split++; - if (split > high) split = high; + // Ensure we make progress even if multiple points have the same timestamp + if (split === low) split++; + if (split > high) split = high; - const leftCount = split - low; - const rightCount = high - split + 1; + const leftCount = split - low; + const rightCount = high - split + 1; - if (leftCount > rightCount) { - high = split - 1; - } else { - low = split; - } + if (leftCount > rightCount) { + high = split - 1; + } else { + low = split; } - - // Remove point at index 'low'. - this.dataLabel.splice(low, 1); - this.hashrateData.splice(low, 1); - this.powerData.splice(low, 1); - this.chartY1Data.splice(low, 1); - this.chartY2Data.splice(low, 1); } + + // Remove point at index 'low'. + this.dataLabel.splice(low, 1); + this.hashrateData.splice(low, 1); + this.powerData.splice(low, 1); + this.chartY1Data.splice(low, 1); + this.chartY2Data.splice(low, 1); } if (this.chartData) { diff --git a/main/http_server/axe-os/src/app/pipes/date-ago.pipe.ts b/main/http_server/axe-os/src/app/pipes/date-ago.pipe.ts index 7148b57d5c..e224ac36eb 100644 --- a/main/http_server/axe-os/src/app/pipes/date-ago.pipe.ts +++ b/main/http_server/axe-os/src/app/pipes/date-ago.pipe.ts @@ -12,9 +12,12 @@ export class DateAgoPipe implements PipeTransform { } transform(value: any, args?: any): any { - if (value) { - value = new Date().getTime() - value * 1000; - let seconds = Math.floor((+new Date() - +new Date(value)) / 1000); + if (value !== undefined && value !== null && value !== '') { + let seconds = Math.floor(Number(value)); + if (seconds > 1e9) { + seconds = Math.floor((Date.now() - seconds) / 1000); + } + if (isNaN(seconds) || seconds < 0) return value; if (!args?.strict && seconds < 29) // less than 30 seconds ago will show as 'Just now' return 'Just now'; const intervals: { [key: string]: number } = { diff --git a/main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.spec.ts b/main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.spec.ts new file mode 100644 index 0000000000..6dd1057405 --- /dev/null +++ b/main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.spec.ts @@ -0,0 +1,24 @@ +import { HeatmapLightnessPipe } from './heatmap-lightness.pipe'; + +describe('HeatmapLightnessPipe', () => { + let pipe: HeatmapLightnessPipe; + + beforeEach(() => { + pipe = new HeatmapLightnessPipe(); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should calculate correct lightness for perfect ratio', () => { + // 500 GH/s domain, 500 GH/s expected, 1 ASIC, 1 domain -> ratio = 1 + const lightness = pipe.transform(500, 500, 1, 1); + expect(lightness).toBe('0.500'); + }); + + it('should handle zero or missing expected hashrate safely', () => { + const lightness = pipe.transform(100, 0, 1, 1); + expect(parseFloat(lightness)).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.ts b/main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.ts new file mode 100644 index 0000000000..5a2dff73df --- /dev/null +++ b/main/http_server/axe-os/src/app/pipes/heatmap-lightness.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'heatmapLightness', + pure: true, + standalone: true +}) +export class HeatmapLightnessPipe implements PipeTransform { + transform(domainHashrate: number, expectedHashrate: number, asicsAmount: number, asicDomainsAmount: number): string { + const expected = expectedHashrate || 1; + const ratio = Math.max(0, Math.min(2, (domainHashrate / expected) * asicsAmount) * asicDomainsAmount); + const deviation = isNaN(ratio) ? 1 : Math.abs(ratio - 1); // 0 = perfect, 1 = 100% off + const t = 1 - Math.pow(1 - deviation, 1.5); // Exponent controls graduality + + const direction = ratio > 1 ? 1 : -1; + const amount = direction * t * 0.4; + const lightness = 0.5 + amount; + + return lightness.toFixed(3); + } +} diff --git a/main/http_server/axe-os/src/app/services/live-data.service.ts b/main/http_server/axe-os/src/app/services/live-data.service.ts index bc94c8dac6..01f3290ee9 100644 --- a/main/http_server/axe-os/src/app/services/live-data.service.ts +++ b/main/http_server/axe-os/src/app/services/live-data.service.ts @@ -53,7 +53,7 @@ export class LiveDataService { const updates$ = merge( this.connect().pipe(switchMap(() => EMPTY), catchError(() => EMPTY)), this.updates$.pipe( - // Buffer updates to handle bursts when tab is resumed + // Buffer updates to handle bursts when tab is resumed (1000ms = 1Hz UI refresh) bufferTime(500), filter(msgs => msgs.length > 0), map(msgs => msgs.reduce((acc, curr) => ({ ...acc, ...curr }), {} as Partial)) From 6d446240491361ff7f18f53ad12051ba0e4f10b3 Mon Sep 17 00:00:00 2001 From: mutatrum Date: Sat, 8 Aug 2026 10:34:52 +0200 Subject: [PATCH 2/2] Shorten reconnect timeout --- .../src/app/services/live-data.service.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/main/http_server/axe-os/src/app/services/live-data.service.ts b/main/http_server/axe-os/src/app/services/live-data.service.ts index 01f3290ee9..7fb5ee75a7 100644 --- a/main/http_server/axe-os/src/app/services/live-data.service.ts +++ b/main/http_server/axe-os/src/app/services/live-data.service.ts @@ -38,12 +38,19 @@ export class LiveDataService { // Periodic polling fallback (adjust frequency based on visibility) const fallbackPolling$ = visibility$.pipe( switchMap(state => { - const interval = state === 'visible' ? 5000 : 60000; // 5s when visible, 60s when hidden - return timer(interval, interval).pipe( + const interval = state === 'visible' ? 3000 : 60000; // 3s when visible, 60s when hidden + return timer(0, interval).pipe( switchMap(() => { // Only poll if not connected OR if backgrounded (to keep data fresh) if (this.connectedSubject.value && state === 'visible') return EMPTY; - return this.systemService.getInfo(); + return this.systemService.getInfo().pipe( + tap(() => { + if (!this.connectedSubject.value) { + this.connectedSubject.next(true); + } + }), + catchError(() => EMPTY) + ); }) ); }), @@ -54,7 +61,7 @@ export class LiveDataService { this.connect().pipe(switchMap(() => EMPTY), catchError(() => EMPTY)), this.updates$.pipe( // Buffer updates to handle bursts when tab is resumed (1000ms = 1Hz UI refresh) - bufferTime(500), + bufferTime(1000), filter(msgs => msgs.length > 0), map(msgs => msgs.reduce((acc, curr) => ({ ...acc, ...curr }), {} as Partial)) ), @@ -109,14 +116,14 @@ export class LiveDataService { }); return this.socket$.pipe( - timeout(30000), + timeout(5000), tap(msg => { this.lastMessageAt = Date.now(); if (msg.event === 'update' && msg.data) { this.updates$.next(msg.data); } }), - retry({ delay: 5000 }), + retry({ delay: 2000 }), share({ resetOnRefCountZero: false }) ); }