Mining/ui/src/app/chart.component.ts
snider 757526e60e feat: Add WebSocket events, simulation mode, and redesigned Miners page
WebSocket Real-Time Events:
- Add EventHub for broadcasting miner events to connected clients
- New event types: miner.starting/started/stopping/stopped/stats/error
- WebSocket endpoint at /ws/events with auto-reconnect support
- Angular WebSocketService with RxJS event streams and fallback to polling

Simulation Mode (miner-ctrl simulate):
- SimulatedMiner generates realistic hashrate data for UI development
- Supports presets: cpu-low, cpu-medium, cpu-high, gpu-ethash, gpu-kawpow
- Features: variance, sine-wave fluctuation, 30s ramp-up, 98% share rate
- XMRig-compatible stats format for full UI compatibility
- NewManagerForSimulation() skips autostart of real miners

Miners Page Redesign:
- Featured cards for installed/recommended miners with gradient styling
- "Installed" (green) and "Recommended" (gold) ribbon badges
- Placeholder cards for 8 planned miners with "Coming Soon" badges
- Algorithm badges, GitHub links, and license info for each miner
- Planned miners: T-Rex, lolMiner, Rigel, BzMiner, SRBMiner, TeamRedMiner, GMiner, NBMiner

Chart Improvements:
- Hybrid data approach: live in-memory data while active, database historical when inactive
- Smoother transitions between data sources

Documentation:
- Updated DEVELOPMENT.md with simulation mode usage
- Updated ARCHITECTURE.md with WebSocket, simulation, and supported miners table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 07:11:41 +00:00

298 lines
9 KiB
TypeScript

import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, effect, Input, ViewEncapsulation, DestroyRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HighchartsChartComponent, ChartConstructorType } from 'highcharts-angular';
import * as Highcharts from 'highcharts';
import { MinerService } from './miner.service';
// More specific type for series with data
type SeriesWithData = Highcharts.SeriesAreaOptions | Highcharts.SeriesSplineOptions;
@Component({
selector: 'snider-mining-chart',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, HighchartsChartComponent],
templateUrl: './chart.component.html',
styleUrls: ['./chart.component.css'],
encapsulation: ViewEncapsulation.None
})
export class ChartComponent {
minerService = inject(MinerService); // Public for template access
private destroyRef = inject(DestroyRef);
Highcharts: typeof Highcharts = Highcharts;
chartConstructor: ChartConstructorType = 'chart';
// Use regular properties instead of signals for Highcharts compatibility
chartOptions: Highcharts.Options;
updateFlag = false;
chartReady = false;
private chartRef: Highcharts.Chart | null = null;
// Callback when chart is created
chartCallback = (chart: Highcharts.Chart) => {
console.log('[Chart] Chart callback called!');
this.chartRef = chart;
this.chartReady = true;
};
// Consistent colors per miner name
private minerColors: Map<string, string> = new Map();
private colorPalette = [
'#6366f1', '#22c55e', '#f59e0b', '#ef4444',
'#8b5cf6', '#06b6d4', '#ec4899', '#84cc16',
];
private nextColorIndex = 0;
private getColorForMiner(minerName: string): string {
if (!this.minerColors.has(minerName)) {
this.minerColors.set(minerName, this.colorPalette[this.nextColorIndex % this.colorPalette.length]);
this.nextColorIndex++;
}
return this.minerColors.get(minerName)!;
}
constructor() {
// Initialize with valid chart options
this.chartOptions = {
...this.createBaseChartOptions(),
chart: {
...this.createBaseChartOptions().chart,
type: 'area'
},
title: { text: '' },
plotOptions: {
area: {
stacking: 'normal',
marker: { enabled: false },
lineWidth: 2,
fillOpacity: 0.3
}
},
series: [] // Start empty, will be populated by effect
};
// Create effect with proper cleanup
const effectRef = effect(() => {
// Hybrid approach: use live in-memory data when available, fall back to database historical data
const liveHistory = this.minerService.hashrateHistory();
const dbHistory = this.minerService.historicalHashrate();
// Merge: prefer live data, supplement with historical for longer time ranges
const historyMap = new Map<string, { timestamp: string; hashrate: number }[]>();
// First, add all historical data as base
dbHistory.forEach((points, name) => {
historyMap.set(name, [...points]);
});
// Then overlay/replace with live data (more recent and accurate)
liveHistory.forEach((points, name) => {
if (points.length > 0) {
const existing = historyMap.get(name) || [];
// Get the earliest live data timestamp
const earliestLive = points.length > 0 ? new Date(points[0].timestamp).getTime() : Infinity;
// Keep historical points before live data starts, then use all live data
const historicalBefore = existing.filter(p => new Date(p.timestamp).getTime() < earliestLive);
historyMap.set(name, [...historicalBefore, ...points]);
}
});
// Clean up colors for miners no longer active
const activeNames = new Set(historyMap.keys());
for (const name of this.minerColors.keys()) {
if (!activeNames.has(name)) {
this.minerColors.delete(name);
}
}
// Build series data with consistent colors per miner
const newSeries: SeriesWithData[] = [];
historyMap.forEach((history, name) => {
const chartData = history.map(point => [new Date(point.timestamp).getTime(), point.hashrate]);
newSeries.push({
type: 'area',
name: name,
data: chartData,
color: this.getColorForMiner(name),
fillOpacity: 0.4
} as SeriesWithData);
});
const yAxisOptions = this.calculateYAxisBoundsForStacked(newSeries);
// Build new chart options
this.chartOptions = {
...this.createBaseChartOptions(),
title: { text: '' },
chart: {
...this.createBaseChartOptions().chart,
type: 'area'
},
legend: {
enabled: historyMap.size > 1,
align: 'center',
verticalAlign: 'bottom',
itemStyle: {
color: '#666',
fontSize: '11px'
}
},
plotOptions: {
area: {
stacking: 'normal',
marker: { enabled: false },
lineWidth: 2,
fillOpacity: 0.3
}
},
yAxis: { ...this.createBaseChartOptions().yAxis, ...yAxisOptions },
series: newSeries
};
// Toggle update flag to trigger Highcharts redraw
this.updateFlag = !this.updateFlag;
});
// Register cleanup
this.destroyRef.onDestroy(() => effectRef.destroy());
}
private calculateYAxisBoundsForSingle(data: number[]): Highcharts.YAxisOptions {
if (data.length === 0) {
return { min: 0, max: 100 }; // Default range when no data
}
const min = Math.min(...data);
const max = Math.max(...data);
// Handle case where all values are 0 or very small
if (max <= 0) {
return { min: 0, max: 100 }; // Default range
}
if (min === max) {
return { min: Math.max(0, min - 50), max: max + 50 };
}
const padding = (max - min) * 0.1;
return {
min: Math.max(0, min - padding),
max: max + padding
};
}
private calculateYAxisBoundsForStacked(series: SeriesWithData[]): Highcharts.YAxisOptions {
const totalsByTimestamp: { [key: number]: number } = {};
series.forEach(s => {
const data = (s as any).data;
if (data) {
(data as [number, number][]).forEach(([timestamp, value]) => {
totalsByTimestamp[timestamp] = (totalsByTimestamp[timestamp] || 0) + value;
});
}
});
const totalValues = Object.values(totalsByTimestamp);
if (totalValues.length === 0) {
return { min: 0, max: 100 }; // Default range when no data
}
const maxTotal = Math.max(...totalValues);
// Handle case where all values are 0 or very small
if (maxTotal <= 0) {
return { min: 0, max: 100 }; // Default range
}
const padding = maxTotal * 0.1;
return {
min: 0,
max: maxTotal + padding
};
}
createBaseChartOptions(): Highcharts.Options {
return {
chart: {
backgroundColor: 'transparent',
style: {
fontFamily: 'var(--font-family-sans, system-ui, sans-serif)'
},
spacing: [10, 10, 10, 10]
},
title: { text: '' },
xAxis: {
type: 'datetime',
title: { text: '' },
lineColor: '#374151',
tickColor: '#374151',
labels: {
style: {
color: '#94a3b8',
fontSize: '11px'
}
},
gridLineWidth: 0
},
yAxis: {
title: { text: '' },
labels: {
style: {
color: '#94a3b8',
fontSize: '11px'
},
formatter: function() {
const val = this.value as number;
if (val >= 1000000) return (val / 1000000).toFixed(1) + ' MH/s';
if (val >= 1000) return (val / 1000).toFixed(1) + ' kH/s';
return val + ' H/s';
}
},
gridLineColor: '#252542',
gridLineDashStyle: 'Dash'
},
legend: {
enabled: false
},
tooltip: {
backgroundColor: '#0f0f1a',
borderColor: '#374151',
borderRadius: 8,
style: {
color: '#fff',
fontSize: '12px'
},
xDateFormat: '%H:%M:%S',
headerFormat: '<span style="font-size: 10px; opacity: 0.8">{point.key}</span><br/>',
pointFormatter: function() {
const val = this.y as number;
let formatted: string;
if (val >= 1000000) formatted = (val / 1000000).toFixed(2) + ' MH/s';
else if (val >= 1000) formatted = (val / 1000).toFixed(2) + ' kH/s';
else formatted = val.toFixed(0) + ' H/s';
return `<span style="color:${this.color}">●</span> ${this.series.name}: <b>${formatted}</b>`;
}
},
plotOptions: {
area: {
fillOpacity: 0.3,
lineWidth: 2,
marker: { enabled: false },
color: '#00d4ff'
},
spline: {
lineWidth: 2.5,
marker: { enabled: false },
color: '#00d4ff'
}
},
series: [],
credits: { enabled: false },
accessibility: { enabled: false }
};
}
}