diff --git a/main/global_state.h b/main/global_state.h index 360c3995d8..3561504038 100644 --- a/main/global_state.h +++ b/main/global_state.h @@ -93,6 +93,10 @@ typedef struct SystemModule bool use_fallback_stratum; bool is_using_fallback; float response_time; + float response_time_min; + float response_time_max; + float last_response_time_min; + float last_response_time_max; uint16_t response_share_batch; float process_time; float cpu_usage; diff --git a/main/http_server/axe-os/src/app/components/dropdown/dropdown.component.html b/main/http_server/axe-os/src/app/components/dropdown/dropdown.component.html new file mode 100644 index 0000000000..8db5ab0b04 --- /dev/null +++ b/main/http_server/axe-os/src/app/components/dropdown/dropdown.component.html @@ -0,0 +1,38 @@ +
+
+ {{ selectedOptionName }} + +
+ @if (isOpen) { + + } +
diff --git a/main/http_server/axe-os/src/app/components/dropdown/dropdown.component.ts b/main/http_server/axe-os/src/app/components/dropdown/dropdown.component.ts index 0a62a8b6d1..f31871325f 100644 --- a/main/http_server/axe-os/src/app/components/dropdown/dropdown.component.ts +++ b/main/http_server/axe-os/src/app/components/dropdown/dropdown.component.ts @@ -5,46 +5,7 @@ import { SelectOption } from '../../models/select-option.model'; @Component({ selector: 'app-dropdown', - template: ` -
-
- {{ selectedOptionName }} - -
- @if (isOpen) { - - } -
- `, + templateUrl: './dropdown.component.html', providers: [ { provide: NG_VALUE_ACCESSOR, 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 915a47cb27..e7eda471b9 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 @@ -221,12 +221,12 @@ + class="w-48 text-sm"> + class="w-48 text-sm"> 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 451f1150a0..c8a01419cb 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 @@ -36,6 +36,7 @@ import { LocalStorageService } from 'src/app/local-storage.service'; import { DashboardEditService } from 'src/app/services/dashboard-edit.service'; import { LayoutService } from 'src/app/layout/service/app.layout.service'; import { SystemInfo as ISystemInfo, SystemStatistics as ISystemStatistics } from 'src/app/generated/models'; +import { eChartLabel } from 'src/models/enum/eChartLabel'; const mockSystemInfo: ISystemInfo = { ASICModel: 'BM1370', @@ -110,6 +111,8 @@ const mockSystemInfo: ISystemInfo = { maxPower: 20, poolConnectionInfo: 'Connected', responseTime: 45, + responseTimeMin: 45, + responseTimeMax: 45, responseShareBatch: 1, poolDifficulty: 1000, blockHeight: 800000, @@ -510,6 +513,46 @@ describe('HomeComponent', () => { expect(component.dataLabel.length).toBe(2); }); + it('should merge response time into closest neighbor when removed point has data in limitDataPoints', () => { + component['statsLimit'] = 3; + component.dataLabel = [1000, 2000, 2050, 4000]; + component.hashrateData = [100, 100, 100, 100]; + component.powerData = [10, 10, 10, 10]; + component.chartDatasets = { + responseTimeMin: [null, 35.5, null, null], + responseTimeMax: [null, 55.5, null, null] + }; + + component.limitDataPoints(0); + + expect(component.dataLabel.length).toBe(3); + expect(component.dataLabel).not.toContain(2000); + expect(component.dataLabel).toContain(2050); + const idx2050 = component.dataLabel.indexOf(2050); + expect(component.chartDatasets['responseTimeMin'][idx2050]).toBe(35.5); + expect(component.chartDatasets['responseTimeMax'][idx2050]).toBe(55.5); + }); + + it('should merge min and max of either points when thinning in limitDataPoints', () => { + component['statsLimit'] = 3; + component.dataLabel = [1000, 2000, 2050, 4000]; + component.hashrateData = [100, 100, 100, 100]; + component.powerData = [10, 10, 10, 10]; + component.chartDatasets = { + responseTimeMin: [null, 35.5, 25.0, null], + responseTimeMax: [null, 45.5, 60.0, null] + }; + + component.limitDataPoints(0); + + expect(component.dataLabel.length).toBe(3); + expect(component.dataLabel).toContain(2050); + expect(component.dataLabel).not.toContain(2000); + const idx2050 = component.dataLabel.indexOf(2050); + expect(component.chartDatasets['responseTimeMin'][idx2050]).toBe(25.0); + expect(component.chartDatasets['responseTimeMax'][idx2050]).toBe(60.0); + }); + it('should clear flash timeouts on destroy', () => { component['shareAcceptedTimeout'] = setTimeout(() => {}, 10000) as any; component['shareRejectedTimeout'] = setTimeout(() => {}, 10000) as any; @@ -523,5 +566,13 @@ describe('HomeComponent', () => { expect(clearTimeout).toHaveBeenCalledWith(component['shareRejectedTimeout']); expect(clearTimeout).toHaveBeenCalledWith(component['workReceivedTimeout']); }); + + it('should return 0 for response time min/max and undefined for others in getMinForLabel', () => { + expect(component.getMinForLabel(eChartLabel.responseTimeMin)).toBe(0); + expect(component.getMinForLabel(eChartLabel.responseTimeMax)).toBe(0); + expect(component.getMinForLabel(eChartLabel.hashrate)).toBeUndefined(); + expect(component.getMinForLabel(eChartLabel.none)).toBeUndefined(); + expect(component.getMinForLabel(undefined)).toBeUndefined(); + }); }); }); 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 8ee665e766..cb21f29751 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 @@ -88,7 +88,7 @@ export class HomeComponent implements OnInit, OnDestroy { public dataLabel: number[] = []; public hashrateData: number[] = []; public powerData: number[] = []; - public chartDatasets: { [key: string]: number[] } = {}; + public chartDatasets: { [key: string]: (number | null)[] } = {}; public chartUnitGroups = ChartUnitGroups; public chartHiddenSensors: Record = {}; public chartData?: any; @@ -525,15 +525,25 @@ export class HomeComponent implements OnInit, OnDestroy { } } - private createChartDatasets( - formControlName: 'chartY1Unit' | 'chartY2Unit', - baseColor: string, - mixColor: string, - fill: boolean, - yAxisID: 'y' | 'y2' - ): any[] { + private createChartDatasets(yAxisID: 'y' | 'y2'): any[] { + const isY1 = yAxisID === 'y'; + const formControlName = isY1 ? 'chartY1Unit' : 'chartY2Unit'; const unit = this.form?.get(formControlName)?.value; - const labels = ChartUnitGroups.find(g => g.value === unit)?.labels || []; + const group = ChartUnitGroups.find(g => g.value === unit); + const labels = group?.labels || []; + const isScatter = group?.type === 'scatter'; + + const showLine = !isScatter; + const fill = isY1 && !isScatter; + const pointRadius = isScatter ? 3 : 2; + const borderWidth = isScatter ? 0 : 1; + + const documentStyle = getComputedStyle(document.documentElement); + const textColorSecondary = documentStyle.getPropertyValue('--color-text-secondary').trim() || '#808080'; + const baseColor = isY1 + ? (documentStyle.getPropertyValue('--color-primary').trim() || '#F80421') + : (documentStyle.getPropertyValue('--chart-axis2-color').trim() || textColorSecondary); + const mixColor = documentStyle.getPropertyValue('--color-text-main').trim() || '#ffffff'; const entries = labels .filter(label => this.isSensorSupported(label, this.latestInfo)) @@ -553,7 +563,7 @@ export class HomeComponent implements OnInit, OnDestroy { const fillIndex = fill ? entries.findIndex(entry => !entry.hidden) : -1; return entries.map(({ labelKey, label, index, hidden }) => { - const borderColor = index === 0 + const borderColor = (index === 0 || isScatter) ? baseColor : `color-mix(in srgb, ${baseColor} ${100 - index * 15}%, ${mixColor} ${index * 15}%)`; const backgroundColor = `color-mix(in srgb, ${borderColor}, transparent 81%)`; @@ -565,10 +575,13 @@ export class HomeComponent implements OnInit, OnDestroy { fill: index === fillIndex, backgroundColor, borderColor, + pointBackgroundColor: borderColor, + pointBorderColor: borderColor, tension: 0, - pointRadius: 2, + showLine, + pointRadius, pointHoverRadius: 5, - borderWidth: 1, + borderWidth, yAxisID, hidden }; @@ -582,23 +595,16 @@ export class HomeComponent implements OnInit, OnDestroy { */ private refreshChartFill(): void { const onFilledAxis = (this.chartData?.datasets ?? []).filter((dataset: any) => dataset.yAxisID === 'y'); - const target = onFilledAxis.find((dataset: any) => !dataset.hidden); + const target = onFilledAxis.find((dataset: any) => !dataset.hidden && dataset.showLine !== false); onFilledAxis.forEach((dataset: any) => { - dataset.fill = dataset === target; + dataset.fill = (dataset === target && dataset.showLine !== false); }); } private rebuildChartDatasets() { - const documentStyle = getComputedStyle(document.documentElement); - const primaryColor = documentStyle.getPropertyValue('--color-primary').trim() || '#F80421'; - const textColor = documentStyle.getPropertyValue('--color-text-main').trim() || '#ffffff'; - const textColorSecondary = documentStyle.getPropertyValue('--color-text-secondary').trim() || '#808080'; - const axis2Color = documentStyle.getPropertyValue('--chart-axis2-color').trim() || textColorSecondary; - const datasets = [ - ...this.createChartDatasets('chartY1Unit', primaryColor, textColor, true, 'y'), - // 'black' as the mix colour darkened the series into the dark background. - ...this.createChartDatasets('chartY2Unit', axis2Color, textColor, false, 'y2') + ...this.createChartDatasets('y'), + ...this.createChartDatasets('y2') ]; if (this.chartData) { @@ -659,6 +665,7 @@ export class HomeComponent implements OnInit, OnDestroy { const textColorSecondary = documentStyle.getPropertyValue('--color-text-secondary').trim(); const surfaceBorder = documentStyle.getPropertyValue('--color-border-content').trim(); const primaryColor = documentStyle.getPropertyValue('--color-primary').trim(); + const axis2Color = documentStyle.getPropertyValue('--chart-axis2-color').trim() || textColorSecondary; this.primaryColorRgb = this.hexToRgb(primaryColor); this.chartData = { @@ -712,6 +719,7 @@ export class HomeComponent implements OnInit, OnDestroy { } }, tooltip: { + filter: (tooltipItem: any) => tooltipItem.raw !== null && tooltipItem.raw !== undefined, callbacks: { label: function (tooltipItem: any) { let label = tooltipItem.dataset.label || ''; @@ -808,7 +816,7 @@ export class HomeComponent implements OnInit, OnDestroy { display: true, position: 'right', ticks: { - color: textColorSecondary, + color: axis2Color, callback: (value: number) => { const y2Dataset = this.chartData?.datasets?.find((d: any) => d.yAxisID === 'y2'); return y2Dataset?.label ? HomeComponent.cbFormatValue(value, y2Dataset.label, {tickmark: true}) : value.toString(); @@ -850,8 +858,8 @@ export class HomeComponent implements OnInit, OnDestroy { stats.labels.forEach((labelKey, labelIdx) => { const valEnum = chartLabelValue(labelKey); if (valEnum === eChartLabel.asicVoltage || valEnum === eChartLabel.voltage || valEnum === eChartLabel.current) { - stats.statistics.forEach((element: number[]) => { - if (element[labelIdx] !== undefined) { + stats.statistics.forEach((element: (number | null)[]) => { + if (element[labelIdx] !== undefined && element[labelIdx] !== null) { element[labelIdx] = element[labelIdx] / 1000; } }); @@ -860,15 +868,19 @@ export class HomeComponent implements OnInit, OnDestroy { this.lastStatsFrequency = 0; if (stats.statistics.length >= 2 && idxTimestamp !== -1) { - const totalDurationMs = stats.statistics[stats.statistics.length - 1][idxTimestamp] - stats.statistics[0][idxTimestamp]; - this.lastStatsFrequency = Math.floor(totalDurationMs / (stats.statistics.length - 1) / 1000); + const lastTs = stats.statistics[stats.statistics.length - 1][idxTimestamp]; + const firstTs = stats.statistics[0][idxTimestamp]; + if (lastTs !== null && firstTs !== null) { + const totalDurationMs = lastTs - firstTs; + this.lastStatsFrequency = Math.floor(totalDurationMs / (stats.statistics.length - 1) / 1000); + } } // 1. Gather existing points only if we are not clearing const existingPoints = clear ? [] : this.dataLabel.map((timestamp, i) => { - const values: Record = {}; + const values: Record = {}; allLabels.forEach(labelKey => { - values[labelKey] = this.chartDatasets[labelKey]?.[i] ?? 0; + values[labelKey] = this.chartDatasets[labelKey]?.[i] ?? null; }); return { timestamp, @@ -879,16 +891,16 @@ export class HomeComponent implements OnInit, OnDestroy { }).sort((a, b) => a.timestamp - b.timestamp); // 2. Always map and sort backend statistics - const backendPoints = stats.statistics.map((element: number[]) => { - const values: Record = {}; + const backendPoints = stats.statistics.map((element: (number | null)[]) => { + const values: Record = {}; allLabels.forEach(labelKey => { const labelIdx = stats.labels.indexOf(labelKey); - values[labelKey] = labelIdx !== -1 ? element[labelIdx] : 0.0; + values[labelKey] = labelIdx !== -1 ? element[labelIdx] : null; }); return { - timestamp: Date.now() - stats.currentTimestamp + element[idxTimestamp], - hashrate: idxHashrate !== -1 ? element[idxHashrate] : 0.0, - power: idxPower !== -1 ? element[idxPower] : 0.0, + timestamp: Date.now() - stats.currentTimestamp + (element[idxTimestamp] ?? 0), + hashrate: (idxHashrate !== -1 ? element[idxHashrate] : 0.0) ?? 0.0, + power: (idxPower !== -1 ? element[idxPower] : 0.0) ?? 0.0, values }; }).sort((a, b) => a.timestamp - b.timestamp); @@ -918,7 +930,7 @@ export class HomeComponent implements OnInit, OnDestroy { if (!this.chartDatasets[labelKey]) { this.chartDatasets[labelKey] = []; } - this.chartDatasets[labelKey].push(p.values[labelKey] ?? 0.0); + this.chartDatasets[labelKey].push(p.values[labelKey] ?? null); }); }); } @@ -1045,11 +1057,25 @@ export class HomeComponent implements OnInit, OnDestroy { this.hashrateData.push(info.hashRate || 0); this.powerData.push(info.power || 0); + const currentSharesAccepted = info.sharesAccepted; + const currentSharesRejected = info.sharesRejected; + const hasNewShare = (this.lastSharesAcceptedCount !== -1 && currentSharesAccepted > this.lastSharesAcceptedCount) || + (this.lastSharesRejectedCount !== -1 && currentSharesRejected > this.lastSharesRejectedCount); + Array.from(new Set([...y1Labels, ...y2Labels])).forEach(labelKey => { if (!this.chartDatasets[labelKey]) { this.chartDatasets[labelKey] = []; } - const val = HomeComponent.getDataForLabel(chartLabelValue(labelKey) as eChartLabel, info); + let val: number | null; + if (labelKey === 'responseTimeMin') { + const minVal = info.responseTimeMin && info.responseTimeMin > 0 ? info.responseTimeMin : info.responseTime; + val = (hasNewShare && minVal > 0) ? minVal : null; + } else if (labelKey === 'responseTimeMax') { + const maxVal = info.responseTimeMax && info.responseTimeMax > 0 ? info.responseTimeMax : info.responseTime; + val = (hasNewShare && maxVal > 0) ? maxVal : null; + } else { + val = HomeComponent.getDataForLabel(chartLabelValue(labelKey) as eChartLabel, info); + } this.chartDatasets[labelKey].push(val); }); @@ -1110,6 +1136,12 @@ export class HomeComponent implements OnInit, OnDestroy { formatted.temp = parseFloat(formatted.temp.toFixed(1)); formatted.temp2 = parseFloat(formatted.temp2.toFixed(1)); formatted.responseTime = parseFloat(formatted.responseTime.toFixed(1)); + if (formatted.responseTimeMin !== undefined) { + formatted.responseTimeMin = parseFloat(formatted.responseTimeMin.toFixed(1)); + } + if (formatted.responseTimeMax !== undefined) { + formatted.responseTimeMax = parseFloat(formatted.responseTimeMax.toFixed(1)); + } return formatted; }), @@ -1419,6 +1451,9 @@ export class HomeComponent implements OnInit, OnDestroy { const y1Label = y1Labels.length > 0 ? y1Labels[0] : 'none'; const y2Label = y2Labels.length > 0 ? y2Labels[0] : 'none'; + this.chartOptions.scales.y.min = this.getMinForLabel(chartLabelValue(y1Label) as eChartLabel); + this.chartOptions.scales.y2.min = this.getMinForLabel(chartLabelValue(y2Label) as eChartLabel); + this.chartOptions.scales.y.suggestedMax = this.getSuggestedMaxForLabel(chartLabelValue(y1Label) as eChartLabel, currentInfo); this.chartOptions.scales.y2.suggestedMax = this.getSuggestedMaxForLabel(chartLabelValue(y2Label) as eChartLabel, currentInfo); @@ -1482,6 +1517,11 @@ export class HomeComponent implements OnInit, OnDestroy { } } + const rMin = this.chartDatasets['responseTimeMin']; + const rMax = this.chartDatasets['responseTimeMax']; + const removedMin = rMin?.[low]; + const removedMax = rMax?.[low]; + // Remove point at index 'low'. this.dataLabel.splice(low, 1); this.hashrateData.splice(low, 1); @@ -1489,6 +1529,20 @@ export class HomeComponent implements OnInit, OnDestroy { Object.keys(this.chartDatasets).forEach(k => { this.chartDatasets[k].splice(low, 1); }); + + // Merge into successor (which shifted into index 'low') + if (rMin && removedMin != null) { + const currentMin = rMin[low]; + if (currentMin == null || removedMin < currentMin) { + rMin[low] = removedMin; + } + } + if (rMax && removedMax != null) { + const currentMax = rMax[low]; + if (currentMax == null || removedMax > currentMax) { + rMax[low] = removedMax; + } + } } if (this.chartData && document.visibilityState !== 'hidden') { @@ -1512,6 +1566,14 @@ export class HomeComponent implements OnInit, OnDestroy { } } + public getMinForLabel(label: eChartLabel | undefined): number | undefined { + switch (label) { + case eChartLabel.responseTimeMin: + case eChartLabel.responseTimeMax: return 0; + default: return undefined; + } + } + public getSuggestedMaxForLabel(label: eChartLabel | undefined, info: ISystemInfo): number { switch (label) { case eChartLabel.hashrate: @@ -1529,7 +1591,8 @@ export class HomeComponent implements OnInit, OnDestroy { case eChartLabel.fanSpeed: return 100; case eChartLabel.fanRpm: return 7000; case eChartLabel.fan2Rpm: return 7000; - case eChartLabel.responseTime: return 50; + case eChartLabel.responseTimeMin: + case eChartLabel.responseTimeMax: return 50; default: return 0; } } @@ -1553,7 +1616,8 @@ export class HomeComponent implements OnInit, OnDestroy { case eChartLabel.fan2Rpm: return info.fan2rpm; case eChartLabel.wifiRssi: return info.wifiRSSI; case eChartLabel.freeHeap: return info.freeHeap; - case eChartLabel.responseTime: return info.responseTime; + case eChartLabel.responseTimeMin: return info.responseTimeMin ?? info.responseTime; + case eChartLabel.responseTimeMax: return info.responseTimeMax ?? info.responseTime; default: return 0.0; } } @@ -1577,7 +1641,8 @@ export class HomeComponent implements OnInit, OnDestroy { case eChartLabel.fan2Rpm: return {suffix: ' rpm', precision: 0}; case eChartLabel.wifiRssi: return {suffix: ' dBm', precision: 0}; case eChartLabel.freeHeap: return {suffix: ' B', precision: 0}; - case eChartLabel.responseTime: return {suffix: ' ms', precision: 1}; + case eChartLabel.responseTimeMin: + case eChartLabel.responseTimeMax: return {suffix: ' ms', precision: 1}; default: return {suffix: '', precision: 0}; } } diff --git a/main/http_server/axe-os/src/app/layout/styles/layout/_theme.scss b/main/http_server/axe-os/src/app/layout/styles/layout/_theme.scss index 2fd2b8ff43..1cb14f0b1d 100644 --- a/main/http_server/axe-os/src/app/layout/styles/layout/_theme.scss +++ b/main/http_server/axe-os/src/app/layout/styles/layout/_theme.scss @@ -62,6 +62,7 @@ --color-overlay-select-bg: #1a2632; --color-overlay-select-border: #2f4562; --color-bg-hover: rgba(255, 255, 255, 0.05); + --chart-axis2-color: #7bd8c0; } .theme-white { diff --git a/main/http_server/axe-os/src/app/services/system.service.ts b/main/http_server/axe-os/src/app/services/system.service.ts index d15e44a230..9b06dea772 100644 --- a/main/http_server/axe-os/src/app/services/system.service.ts +++ b/main/http_server/axe-os/src/app/services/system.service.ts @@ -166,6 +166,8 @@ export class SystemApiService { fallbackStratumV2ChannelType: "extended" as const, poolDifficulty: 1000, responseTime: 10, + responseTimeMin: 10, + responseTimeMax: 10, responseShareBatch: 1, isUsingFallbackStratum: 0, useFallbackStratum: 0, @@ -265,7 +267,7 @@ export class SystemApiService { const timestampData = [13131,18126,23125,28125,33125,38125,43125,48125,53125,58125]; columnList.push("timestamp"); - let statisticsList: number[][] = []; + let statisticsList: (number | null)[][] = []; for(let i: number = 0; i < 10; i++) { statisticsList[i] = []; @@ -286,8 +288,9 @@ export class SystemApiService { case eChartLabel.fanRpm: statisticsList[i][j] = fanRpmData[i]; break; case eChartLabel.fan2Rpm: statisticsList[i][j] = fan2RpmData[i]; break; case eChartLabel.wifiRssi: statisticsList[i][j] = wifiRssiData[i]; break; - case eChartLabel.freeHeap: statisticsList[i][j] = freeHeapData[i]; break; - case eChartLabel.responseTime: statisticsList[i][j] = responseTimeData[i]; break; + case eChartLabel.freeHeap: statisticsList[i][j] = freeHeapData[i]; break; + case eChartLabel.responseTimeMin: statisticsList[i][j] = responseTimeData[i] ? responseTimeData[i] - 1 : null; break; + case eChartLabel.responseTimeMax: statisticsList[i][j] = responseTimeData[i] ? responseTimeData[i] + 1 : null; break; default: if (columnList[j] === "timestamp") { statisticsList[i][j] = timestampData[i]; diff --git a/main/http_server/axe-os/src/models/enum/eChartLabel.ts b/main/http_server/axe-os/src/models/enum/eChartLabel.ts index b5ae3c06b1..1a6167a68a 100644 --- a/main/http_server/axe-os/src/models/enum/eChartLabel.ts +++ b/main/http_server/axe-os/src/models/enum/eChartLabel.ts @@ -16,11 +16,19 @@ export enum eChartLabel { fan2Rpm = 'Fan 2 RPM', wifiRssi = 'Wi-Fi RSSI', freeHeap = 'Free Heap', - responseTime = 'Response Time', + responseTimeMin = 'Response Time Min', + responseTimeMax = 'Response Time Max', none = 'None' } -export const ChartUnitGroups: { name: string, value: string, labels: string[] }[] = [ +export interface IChartUnitGroup { + name: string; + value: string; + labels: string[]; + type?: 'line' | 'scatter'; +} + +export const ChartUnitGroups: IChartUnitGroup[] = [ { name: 'Hashrate (H/s)', value: 'hashrate', labels: ['hashrate', 'hashrate_1m', 'hashrate_10m', 'hashrate_1h'] }, { name: 'Temperature (°C)', value: 'temperature', labels: ['asicTemp', 'asicTemp2', 'vrTemp'] }, { name: 'Voltage (V)', value: 'voltage', labels: ['asicVoltage', 'voltage'] }, @@ -30,7 +38,7 @@ export const ChartUnitGroups: { name: string, value: string, labels: string[] }[ { name: 'Percentage (%)', value: 'percentage', labels: ['fanSpeed', 'errorPercentage'] }, { name: 'Wi-Fi RSSI (dBm)', value: 'rssi', labels: ['wifiRssi'] }, { name: 'Free Heap (Bytes)', value: 'heap', labels: ['freeHeap'] }, - { name: 'Response Time (ms)', value: 'time', labels: ['responseTime'] }, + { name: 'Response Time (ms)', value: 'time', labels: ['responseTimeMin', 'responseTimeMax'], type: 'scatter' }, { name: 'None', value: 'none', labels: ['none'] } ]; diff --git a/main/http_server/http_server.c b/main/http_server/http_server.c index 807c8e4e70..328ffa8f04 100644 --- a/main/http_server/http_server.c +++ b/main/http_server/http_server.c @@ -74,7 +74,8 @@ static const char * STATS_LABEL_FAN_RPM = "fanRpm"; static const char * STATS_LABEL_FAN2_RPM = "fan2Rpm"; static const char * STATS_LABEL_WIFI_RSSI = "wifiRssi"; static const char * STATS_LABEL_FREE_HEAP = "freeHeap"; -static const char * STATS_LABEL_RESPONSE_TIME = "responseTime"; +static const char * STATS_LABEL_RESPONSE_TIME_MIN = "responseTimeMin"; +static const char * STATS_LABEL_RESPONSE_TIME_MAX = "responseTimeMax"; static int system_info_prebuffer_len = 256; static int system_wifi_scan_prebuffer_len = 256; @@ -99,7 +100,8 @@ typedef enum SRC_FAN2_RPM, SRC_WIFI_RSSI, SRC_FREE_HEAP, - SRC_RESPONSE_TIME, + SRC_RESPONSE_TIME_MIN, + SRC_RESPONSE_TIME_MAX, SRC_NONE // last } DataSource; @@ -123,7 +125,8 @@ DataSource strToDataSource(const char * sourceStr) if (strcmp(sourceStr, STATS_LABEL_FAN2_RPM) == 0) return SRC_FAN2_RPM; if (strcmp(sourceStr, STATS_LABEL_WIFI_RSSI) == 0) return SRC_WIFI_RSSI; if (strcmp(sourceStr, STATS_LABEL_FREE_HEAP) == 0) return SRC_FREE_HEAP; - if (strcmp(sourceStr, STATS_LABEL_RESPONSE_TIME) == 0) return SRC_RESPONSE_TIME; + if (strcmp(sourceStr, STATS_LABEL_RESPONSE_TIME_MIN) == 0) return SRC_RESPONSE_TIME_MIN; + if (strcmp(sourceStr, STATS_LABEL_RESPONSE_TIME_MAX) == 0) return SRC_RESPONSE_TIME_MAX; } return SRC_NONE; } @@ -1687,7 +1690,8 @@ static esp_err_t GET_system_statistics(httpd_req_t * req) if (dataSelection[SRC_FAN2_RPM]) { yyjson_mut_arr_add_str(doc, labelArray, STATS_LABEL_FAN2_RPM); } if (dataSelection[SRC_WIFI_RSSI]) { yyjson_mut_arr_add_str(doc, labelArray, STATS_LABEL_WIFI_RSSI); } if (dataSelection[SRC_FREE_HEAP]) { yyjson_mut_arr_add_str(doc, labelArray, STATS_LABEL_FREE_HEAP); } - if (dataSelection[SRC_RESPONSE_TIME]) { yyjson_mut_arr_add_str(doc, labelArray, STATS_LABEL_RESPONSE_TIME); } + if (dataSelection[SRC_RESPONSE_TIME_MIN]) { yyjson_mut_arr_add_str(doc, labelArray, STATS_LABEL_RESPONSE_TIME_MIN); } + if (dataSelection[SRC_RESPONSE_TIME_MAX]) { yyjson_mut_arr_add_str(doc, labelArray, STATS_LABEL_RESPONSE_TIME_MAX); } yyjson_mut_arr_add_str(doc, labelArray, STATS_LABEL_TIMESTAMP); yyjson_mut_val *statsArray = yyjson_mut_obj_add_arr(doc, root, "statistics"); @@ -1713,7 +1717,20 @@ static esp_err_t GET_system_statistics(httpd_req_t * req) if (dataSelection[SRC_FAN2_RPM]) { yyjson_mut_arr_add_uint(doc, valueArray, statsData.fan2RPM); } if (dataSelection[SRC_WIFI_RSSI]) { yyjson_mut_arr_add_sint(doc, valueArray, statsData.wifiRSSI); } if (dataSelection[SRC_FREE_HEAP]) { yyjson_mut_arr_add_uint(doc, valueArray, statsData.freeHeap); } - if (dataSelection[SRC_RESPONSE_TIME]) { yyjson_mut_arr_add_real(doc, valueArray, statsData.responseTime); } + if (dataSelection[SRC_RESPONSE_TIME_MIN]) { + if (statsData.responseTimeMin > 0.0f) { + yyjson_mut_arr_add_real(doc, valueArray, statsData.responseTimeMin); + } else { + yyjson_mut_arr_add_null(doc, valueArray); + } + } + if (dataSelection[SRC_RESPONSE_TIME_MAX]) { + if (statsData.responseTimeMax > 0.0f) { + yyjson_mut_arr_add_real(doc, valueArray, statsData.responseTimeMax); + } else { + yyjson_mut_arr_add_null(doc, valueArray); + } + } yyjson_mut_arr_add_uint(doc, valueArray, statsData.timestamp); } diff --git a/main/http_server/openapi.yaml b/main/http_server/openapi.yaml index 923a6fb568..5e93fbb498 100644 --- a/main/http_server/openapi.yaml +++ b/main/http_server/openapi.yaml @@ -302,6 +302,8 @@ components: - power - resetReason - responseTime + - responseTimeMin + - responseTimeMax - runningPartition - sharesAccepted - sharesRejected @@ -430,6 +432,12 @@ components: responseTime: type: number description: Pool response time in ms + responseTimeMin: + type: number + description: Minimum pool response time in ms for current tick + responseTimeMax: + type: number + description: Maximum pool response time in ms for current tick responseShareBatch: type: number description: Number of shares acknowledged in the batch that produced responseTime (SV2; 1 = single share, >1 = batched ack) @@ -824,6 +832,7 @@ components: description: Statistics data values(s) items: type: number + nullable: true SystemScoreboardEntry: type: object @@ -1168,7 +1177,7 @@ paths: type: array items: type: string - example: [hashrate,hashrate_1m,hashrate_10m,hashrate_1h,asicTemp,vrTemp,asicVoltage,voltage,power,current,fanSpeed,fanRpm,fan2Rpm,wifiRssi,freeHeap,responseTime] + example: [hashrate,hashrate_1m,hashrate_10m,hashrate_1h,asicTemp,vrTemp,asicVoltage,voltage,power,current,fanSpeed,fanRpm,fan2Rpm,wifiRssi,freeHeap,responseTimeMin,responseTimeMax] description: List of labels for which data should be retrieved tags: - system diff --git a/main/http_server/system_api_json.c b/main/http_server/system_api_json.c index 99c0582ba0..140551acb6 100644 --- a/main/http_server/system_api_json.c +++ b/main/http_server/system_api_json.c @@ -73,6 +73,10 @@ static void system_api_add_telemetry(cJSON *root, GlobalState *g) { cJSON_AddNumberToObject(root, "bestSessionDiff", g->SYSTEM_MODULE.best_session_nonce_diff); cJSON_AddNumberToObject(root, "poolDifficulty", g->SYSTEM_MODULE.pool_difficulty); cJSON_AddFloatToObject(root, "responseTime", g->SYSTEM_MODULE.response_time); + float rt_min = g->SYSTEM_MODULE.response_time_min > 0.0f ? g->SYSTEM_MODULE.response_time_min : g->SYSTEM_MODULE.last_response_time_min; + float rt_max = g->SYSTEM_MODULE.response_time_max > 0.0f ? g->SYSTEM_MODULE.response_time_max : g->SYSTEM_MODULE.last_response_time_max; + cJSON_AddFloatToObject(root, "responseTimeMin", rt_min > 0.0f ? rt_min : g->SYSTEM_MODULE.response_time); + cJSON_AddFloatToObject(root, "responseTimeMax", rt_max > 0.0f ? rt_max : g->SYSTEM_MODULE.response_time); cJSON_AddNumberToObject(root, "responseShareBatch", g->SYSTEM_MODULE.response_share_batch); cJSON_AddFloatToObject(root, "processTime", g->SYSTEM_MODULE.process_time); cJSON_AddNumberToObject(root, "workReceived", g->SYSTEM_MODULE.work_received); diff --git a/main/system.c b/main/system.c index 3c12e4b427..edb216b14c 100644 --- a/main/system.c +++ b/main/system.c @@ -518,6 +518,10 @@ void SYSTEM_reset_pool_session(GlobalState * GLOBAL_STATE) module->shares_rejected = 0; module->shares_pending = 0; module->response_time = 0.0f; + module->response_time_min = 0.0f; + module->response_time_max = 0.0f; + module->last_response_time_min = 0.0f; + module->last_response_time_max = 0.0f; module->response_share_batch = 0; module->pool_difficulty = 0.0; @@ -525,6 +529,20 @@ void SYSTEM_reset_pool_session(GlobalState * GLOBAL_STATE) reset_coinbase_ui_state(GLOBAL_STATE, ""); } +void SYSTEM_record_response_time(GlobalState * GLOBAL_STATE, float response_time_ms, uint16_t batch_count) +{ + if (!GLOBAL_STATE || response_time_ms <= 0.0f) return; + SystemModule *module = &GLOBAL_STATE->SYSTEM_MODULE; + module->response_time = response_time_ms; + module->response_share_batch = batch_count; + if (module->response_time_min <= 0.0f || response_time_ms < module->response_time_min) { + module->response_time_min = response_time_ms; + } + if (module->response_time_max <= 0.0f || response_time_ms > module->response_time_max) { + module->response_time_max = response_time_ms; + } +} + void SYSTEM_decode_and_apply_coinbase(GlobalState * GLOBAL_STATE, const miner_job_t * job) { if (!GLOBAL_STATE || !job) return; diff --git a/main/system.h b/main/system.h index d45c932a0a..d03d032ead 100644 --- a/main/system.h +++ b/main/system.h @@ -20,6 +20,7 @@ void SYSTEM_notify_accepted_share(GlobalState * GLOBAL_STATE); void SYSTEM_notify_rejected_share(GlobalState * GLOBAL_STATE, char * error_msg); void SYSTEM_notify_found_nonce(GlobalState * GLOBAL_STATE, double diff, bool is_block); void SYSTEM_notify_new_ntime(GlobalState * GLOBAL_STATE, uint32_t ntime); +void SYSTEM_record_response_time(GlobalState * GLOBAL_STATE, float response_time_ms, uint16_t batch_count); void SYSTEM_decode_and_apply_coinbase(GlobalState * GLOBAL_STATE, const miner_job_t * job); diff --git a/main/tasks/statistics_task.c b/main/tasks/statistics_task.c index 086b93284e..5d046b7d57 100644 --- a/main/tasks/statistics_task.c +++ b/main/tasks/statistics_task.c @@ -54,6 +54,20 @@ void removeStatisticsBuffer() } } +static inline void merge_min_positive(float *target, float val) +{ + if (val > 0.0f && (*target <= 0.0f || val < *target)) { + *target = val; + } +} + +static inline void merge_max_positive(float *target, float val) +{ + if (val > 0.0f && (*target <= 0.0f || val > *target)) { + *target = val; + } +} + bool addStatisticData(StatisticsDataPtr data, uint16_t statsFrequency) { bool result = false; @@ -113,9 +127,18 @@ bool addStatisticData(StatisticsDataPtr data, uint16_t statsFrequency) indexToRemove = low; } + struct StatisticsData *toRemove = &statisticsBuffer[indexToRemove]; + struct StatisticsData *successor = &statisticsBuffer[indexToRemove + 1]; + + // Merge into successor before removing indexToRemove + if (indexToRemove > 0) { + merge_min_positive(&successor->responseTimeMin, toRemove->responseTimeMin); + merge_max_positive(&successor->responseTimeMax, toRemove->responseTimeMax); + } + // Shift and append (Standard linear array shift) if (indexToRemove < maxDataCount - 1) { - memmove(&statisticsBuffer[indexToRemove], &statisticsBuffer[indexToRemove + 1], (maxDataCount - indexToRemove - 1) * sizeof(struct StatisticsData)); + memmove(toRemove, successor, (maxDataCount - indexToRemove - 1) * sizeof(struct StatisticsData)); } statisticsBuffer[maxDataCount - 1] = *data; result = true; @@ -155,6 +178,7 @@ void statistics_task(void * pvParameters) SystemModule * sys_module = &GLOBAL_STATE->SYSTEM_MODULE; PowerManagementModule * power_management = &GLOBAL_STATE->POWER_MANAGEMENT_MODULE; struct StatisticsData statsData = {}; + uint64_t last_shares_count = sys_module->shares_accepted + sys_module->shares_rejected; TickType_t taskWakeTime = xTaskGetTickCount(); @@ -186,7 +210,22 @@ void statistics_task(void * pvParameters) statsData.fan2RPM = power_management->fan2_rpm; statsData.wifiRSSI = wifiRSSI; statsData.freeHeap = esp_get_free_heap_size(); - statsData.responseTime = sys_module->response_time; + + uint64_t current_shares = sys_module->shares_accepted + sys_module->shares_rejected; + if (current_shares != last_shares_count && sys_module->response_time > 0.0f) { + statsData.responseTimeMin = sys_module->response_time_min > 0.0f ? sys_module->response_time_min : sys_module->response_time; + statsData.responseTimeMax = sys_module->response_time_max > 0.0f ? sys_module->response_time_max : sys_module->response_time; + sys_module->last_response_time_min = statsData.responseTimeMin; + sys_module->last_response_time_max = statsData.responseTimeMax; + } else { + statsData.responseTimeMin = 0.0f; + statsData.responseTimeMax = 0.0f; + sys_module->last_response_time_min = 0.0f; + sys_module->last_response_time_max = 0.0f; + } + last_shares_count = current_shares; + sys_module->response_time_min = 0.0f; + sys_module->response_time_max = 0.0f; addStatisticData(&statsData, configStatsFrequency); } diff --git a/main/tasks/statistics_task.h b/main/tasks/statistics_task.h index bb3eabc225..52bf88540a 100644 --- a/main/tasks/statistics_task.h +++ b/main/tasks/statistics_task.h @@ -28,7 +28,8 @@ struct StatisticsData uint16_t fan2RPM; int8_t wifiRSSI; uint32_t freeHeap; - float responseTime; + float responseTimeMin; + float responseTimeMax; }; bool getStatisticData(uint16_t index, StatisticsDataPtr dataOut); diff --git a/main/tasks/stratum_v1_client.c b/main/tasks/stratum_v1_client.c index a84231dfd0..5e72dba521 100644 --- a/main/tasks/stratum_v1_client.c +++ b/main/tasks/stratum_v1_client.c @@ -390,10 +390,11 @@ esp_err_t stratum_v1_run(GlobalState *GLOBAL_STATE, uint16_t pool_idx) if (s_v1_msg->response_success) { ESP_LOGI(TAG, "message result accepted"); ESP_LOGI(TAG, "Stratum response time: %.1f ms", response_time_ms); - GLOBAL_STATE->SYSTEM_MODULE.response_time = response_time_ms; + SYSTEM_record_response_time(GLOBAL_STATE, response_time_ms, 1); SYSTEM_notify_accepted_share(GLOBAL_STATE); } else { ESP_LOGW(TAG, "message result rejected: %s", s_v1_msg->error_str); + SYSTEM_record_response_time(GLOBAL_STATE, response_time_ms, 1); SYSTEM_notify_rejected_share(GLOBAL_STATE, s_v1_msg->error_str); } } else { diff --git a/main/tasks/stratum_v2_client.c b/main/tasks/stratum_v2_client.c index 1a72c02b39..cf2ffaa3e5 100644 --- a/main/tasks/stratum_v2_client.c +++ b/main/tasks/stratum_v2_client.c @@ -804,8 +804,7 @@ esp_err_t stratum_v2_run(GlobalState *GLOBAL_STATE, uint16_t pool_idx) if (submit_time_us > 0) { float response_time_ms = (float)(esp_timer_get_time() - submit_time_us) / 1000.0f; ESP_LOGI(TAG, "Shares accepted: %lu (%.1f ms)", accepted_count, response_time_ms); - GLOBAL_STATE->SYSTEM_MODULE.response_time = response_time_ms; - GLOBAL_STATE->SYSTEM_MODULE.response_share_batch = (uint16_t)accepted_count; + SYSTEM_record_response_time(GLOBAL_STATE, response_time_ms, (uint16_t)accepted_count); stratum_v2_submit_time_us[slot] = 0; } else { ESP_LOGI(TAG, "Shares accepted: %lu", accepted_count);