diff --git a/pkg/mining/service.go b/pkg/mining/service.go
index aa54dde..4ff8b65 100644
--- a/pkg/mining/service.go
+++ b/pkg/mining/service.go
@@ -17,7 +17,7 @@ import (
"github.com/adrg/xdg"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
- "github.com/shirou/gopsutil/v4/mem" // Import mem for memory stats
+ "github.com/shirou/gopsutil/v4/mem"
"github.com/swaggo/swag"
swaggerFiles "github.com/swaggo/files"
@@ -142,18 +142,26 @@ func (s *Service) handleGetInfo(c *gin.Context) {
return
}
- systemInfo.Timestamp = time.Now()
- vMem, err := mem.VirtualMemory()
- if err == nil {
- systemInfo.TotalSystemRAMGB = float64(vMem.Total) / (1024 * 1024 * 1024)
- }
-
c.JSON(http.StatusOK, systemInfo)
}
// updateInstallationCache performs a live check and updates the cache file.
func (s *Service) updateInstallationCache() (*SystemInfo, error) {
- var allDetails []*InstallationDetails
+ // Always create a complete SystemInfo object
+ systemInfo := &SystemInfo{
+ Timestamp: time.Now(),
+ OS: runtime.GOOS,
+ Architecture: runtime.GOARCH,
+ GoVersion: runtime.Version(),
+ AvailableCPUCores: runtime.NumCPU(),
+ InstalledMinersInfo: []*InstallationDetails{}, // Initialize as empty slice
+ }
+
+ vMem, err := mem.VirtualMemory()
+ if err == nil {
+ systemInfo.TotalSystemRAMGB = float64(vMem.Total) / (1024 * 1024 * 1024)
+ }
+
for _, availableMiner := range s.Manager.ListAvailableMiners() {
var miner Miner
switch availableMiner.Name {
@@ -163,16 +171,7 @@ func (s *Service) updateInstallationCache() (*SystemInfo, error) {
continue
}
details, _ := miner.CheckInstallation()
- allDetails = append(allDetails, details)
- }
-
- systemInfo := &SystemInfo{
- Timestamp: time.Now(),
- OS: runtime.GOOS,
- Architecture: runtime.GOARCH,
- GoVersion: runtime.Version(),
- AvailableCPUCores: runtime.NumCPU(),
- InstalledMinersInfo: allDetails,
+ systemInfo.InstalledMinersInfo = append(systemInfo.InstalledMinersInfo, details)
}
configDir, err := xdg.ConfigFile("lethean-desktop/miners")
diff --git a/ui/src/app/app.css b/ui/src/app/app.css
index 97043a3..4af0439 100644
--- a/ui/src/app/app.css
+++ b/ui/src/app/app.css
@@ -38,26 +38,35 @@
gap: 0.5rem;
}
-.miner-list {
+.dashboard-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-top: 1rem;
+ padding-bottom: 1rem; /* Add padding to separate from charts */
+ border-bottom: 1px solid #e0e0e0; /* Visual separator */
+}
+
+.miner-summary-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem;
+ border-radius: 0.25rem;
+ border: 1px solid #e0e0e0;
+}
+
+.dashboard-charts {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
-.miner-item-container {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- padding: 0.75rem;
- border-radius: 0.25rem;
+.miner-chart-item {
border: 1px solid #e0e0e0;
-}
-
-.miner-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
+ border-radius: 0.25rem;
+ padding: 0.5rem;
}
.miner-name {
@@ -92,3 +101,32 @@ wa-button {
wa-spinner {
margin: 1rem 0;
}
+
+/* Admin Panel specific styles (moved from app.css) */
+.admin-panel {
+ padding: 1rem;
+ border-top: 1px solid #e0e0e0;
+ margin-top: 1rem;
+ border-radius: 0.25rem;
+ border: 1px solid #e0e0e0;
+}
+
+.admin-title {
+ margin-top: 0;
+ border-bottom: 1px solid #e0e0e0;
+ padding-bottom: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.path-list ul {
+ list-style: none;
+ padding: 0.5rem;
+ margin: 0;
+ font-family: monospace;
+ border-radius: 0.25rem;
+ border: 1px solid #e0e0e0;
+}
+
+.path-list li {
+ padding: 0.25rem 0;
+}
diff --git a/ui/src/app/app.html b/ui/src/app/app.html
index b5627e0..e654c25 100644
--- a/ui/src/app/app.html
+++ b/ui/src/app/app.html
@@ -1,25 +1,42 @@
-
- @if (systemInfo === null && !needsSetup) {
-
-
- Loading...
-
-
+
+
+
- }
-
- @if (needsSetup) {
-
-
-
-
- Welcome! Let's Get Started
-
+
+ @if (systemInfo === null && !needsSetup) {
+
+
+
+ } @else if (!apiAvailable) {
+
+
+
API Not Available. Please ensure the mining service is running.
+
+
+ Retry
+
+
+ } @else if (apiAvailable && needsSetup) {
+
+
To begin, please install a miner from the list below.
-
Available Miners
@for (miner of manageableMiners; track miner.name) {
@@ -44,20 +61,9 @@
}
-
-
-
-
- Refresh Status
-
-
-
-
- }
-
-
- @if (systemInfo !== null && apiAvailable && !needsSetup) {
-
+
+ } @else if (apiAvailable && !needsSetup) {
+
@if (error) {
@@ -68,147 +74,155 @@
}
-
-
+
+ @if (showAdminPanel) {
+
+
Admin Panel
-
- @if (showAdminPanel) {
-
-
Admin Panel
-
-
Manage Miners
-
- @for (miner of manageableMiners; track miner.name) {
-
- {{ miner.name }}
- @if (miner.is_installed) {
-
- @if (actionInProgress === 'uninstall-' + miner.name) {
-
- } @else {
-
- Uninstall
- }
-
- } @else {
-
- @if (actionInProgress === 'install-' + miner.name) {
-
- } @else {
-
- Install
- }
-
- }
-
- } @empty {
-
- Could not load available miners.
-
- }
-
-
-
Antivirus Whitelist Paths
-
-
To prevent antivirus software from interfering, please add the following paths to your exclusion list:
-
- @for (path of whitelistPaths; track path) {
- {{ path }}
- }
-
-
-
- }
-
-
- @if (installedMiners.length > 0) {
+
Manage Miners
- @for (miner of installedMiners; track miner.path) {
-
-
-
- {{ miner.type }}
-
- Version: {{ miner.version }}
-
-
-
+ @for (miner of manageableMiners; track miner.name) {
+
+ {{ miner.name }}
+ @if (miner.is_installed) {
+
+ @if (actionInProgress === 'uninstall-' + miner.name) {
+
+ } @else {
+
+ Uninstall
+ }
+
+ } @else {
+
+ @if (actionInProgress === 'install-' + miner.name) {
+
+ } @else {
+
+ Install
+ }
+
+ }
+
+ } @empty {
+
+ Could not load available miners.
+
+ }
+
- @if (isMinerRunning(miner)) {
+
Antivirus Whitelist Paths
+
+
To prevent antivirus software from interfering, please add the following paths to your exclusion list:
+
+ @for (path of whitelistPaths; track path) {
+ {{ path }}
+ } @empty {
+ - No paths to display. Install a miner to see required paths.
+ }
+
+
+
+ } @else {
+
+ @if (installedMiners.length > 0) {
+
+ @for (miner of installedMiners; track miner.path) {
+
+
+ {{ miner.type }}
+
+ Version: {{ miner.version }}
+
+
+
+
+ @if (isMinerRunning(miner)) {
+
+ @if (actionInProgress === 'stop-' + miner.type) {
+
+ } @else {
+
+ Stop
+ }
+
+ } @else {
+
-
- @if (showStartOptionsFor === miner.type) {
-
-
-
- @if (actionInProgress === 'start-' + miner.type) {
-
- } @else {
-
- Confirm & Start
- }
+ (click)="toggleStartOptions(miner.type)">
+
+ New Config
}
+
+ @if (showStartOptionsFor === miner.type) {
+
+
+
+
+ @if (actionInProgress === 'start-' + miner.type) {
+
+ } @else {
+
+ Confirm & Start
+ }
+
+
+ }
}
- }
-
-
- }
+
+
+ @for (miner of installedMiners; track miner.path) {
+ @if (isMinerRunning(miner) && chartOptionsMap.has(getRunningMinerInstance(miner).name)) {
+
+
+
+ }
+ }
+
+ } @else {
+
+
No miners installed. Open the Admin Panel to install one.
+
+ }
+ }
+ }
+
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts
index 05edeab..5a572f8 100644
--- a/ui/src/app/app.ts
+++ b/ui/src/app/app.ts
@@ -1,6 +1,7 @@
import {
Component,
OnInit,
+ OnDestroy,
ElementRef,
ViewEncapsulation,
CUSTOM_ELEMENTS_SCHEMA
@@ -8,8 +9,10 @@ import {
import { HttpClient, HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { of, forkJoin } from 'rxjs';
-import { switchMap, catchError, map } from 'rxjs/operators';
+import { of, forkJoin, Subscription, interval } from 'rxjs';
+import { switchMap, catchError, map, startWith } from 'rxjs/operators';
+import { HighchartsChartComponent, ChartConstructorType } from 'highcharts-angular'; // Corrected import
+import * as Highcharts from 'highcharts';
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
@@ -20,7 +23,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/input/input.js';
-// Define interfaces for our data structures
+// Define interfaces
interface InstallationDetails {
is_installed: boolean;
version: string;
@@ -35,16 +38,21 @@ interface AvailableMiner {
description: string;
}
+interface HashratePoint {
+ timestamp: string;
+ hashrate: number;
+}
+
@Component({
selector: 'snider-mining-dashboard',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
- imports: [CommonModule, HttpClientModule, FormsModule],
+ imports: [CommonModule, HttpClientModule, FormsModule, HighchartsChartComponent], // Corrected import
templateUrl: './app.html',
styleUrls: ["app.css"],
encapsulation: ViewEncapsulation.ShadowDom
})
-export class MiningDashboardElementComponent implements OnInit {
+export class MiningDashboardElementComponent implements OnInit, OnDestroy {
apiBaseUrl: string = 'http://localhost:9090/api/v1/mining';
// State management
@@ -60,6 +68,14 @@ export class MiningDashboardElementComponent implements OnInit {
installedMiners: InstallationDetails[] = [];
whitelistPaths: string[] = [];
+ // Charting
+ Highcharts: typeof Highcharts = Highcharts;
+ chartOptionsMap: Map = new Map();
+ chartConstructor: ChartConstructorType = 'chart';
+ updateFlag: boolean = false;
+ oneToOneFlag: boolean = true;
+ private statsSubscription: Subscription | undefined;
+
// Form inputs
poolAddress: string = 'pool.hashvault.pro:80';
walletAddress: string = '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H';
@@ -71,6 +87,10 @@ export class MiningDashboardElementComponent implements OnInit {
this.checkSystemState();
}
+ ngOnDestroy(): void {
+ this.stopStatsPolling();
+ }
+
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress = null;
@@ -92,7 +112,6 @@ export class MiningDashboardElementComponent implements OnInit {
switchMap(({ available, info }) => {
this.apiAvailable = true;
this.systemInfo = info;
-
const trulyInstalledMiners = (info.installed_miners_info || []).filter((m: InstallationDetails) => m.is_installed);
if (trulyInstalledMiners.length === 0) {
@@ -100,21 +119,14 @@ export class MiningDashboardElementComponent implements OnInit {
this.manageableMiners = available.map(availMiner => ({ ...availMiner, is_installed: false }));
this.installedMiners = [];
this.runningMiners = [];
+ this.stopStatsPolling();
return of(null);
}
this.needsSetup = false;
- const installedMap = new Map(
- (info.installed_miners_info || []).map((m: InstallationDetails) => [this.getMinerType(m), m])
- );
-
- this.manageableMiners = available.map(availMiner => ({
- ...availMiner,
- is_installed: installedMap.get(availMiner.name)?.is_installed ?? false,
- }));
-
+ const installedMap = new Map((info.installed_miners_info || []).map((m: InstallationDetails) => [this.getMinerType(m), m]));
+ this.manageableMiners = available.map(availMiner => ({ ...availMiner, is_installed: installedMap.get(availMiner.name)?.is_installed ?? false }));
this.installedMiners = trulyInstalledMiners.map((m: InstallationDetails) => ({ ...m, type: this.getMinerType(m) }));
-
this.updateWhitelistPaths();
return this.fetchRunningMiners();
}),
@@ -144,7 +156,15 @@ export class MiningDashboardElementComponent implements OnInit {
fetchRunningMiners() {
return this.http.get(`${this.apiBaseUrl}/miners`).pipe(
- map(miners => { this.runningMiners = miners; this.updateWhitelistPaths(); }),
+ map(miners => {
+ this.runningMiners = miners;
+ this.updateWhitelistPaths();
+ if (this.runningMiners.length > 0 && !this.statsSubscription) {
+ this.startStatsPolling();
+ } else if (this.runningMiners.length === 0) {
+ this.stopStatsPolling();
+ }
+ }),
catchError(err => {
this.handleError(err, 'Could not fetch running miners');
this.runningMiners = [];
@@ -153,6 +173,66 @@ export class MiningDashboardElementComponent implements OnInit {
);
}
+ startStatsPolling(): void {
+ this.stopStatsPolling();
+ this.statsSubscription = interval(5000).pipe(
+ startWith(0),
+ switchMap(() => forkJoin(
+ this.runningMiners.map(miner =>
+ this.http.get(`${this.apiBaseUrl}/miners/${miner.name}/hashrate-history`).pipe(
+ map(history => ({ name: miner.name, history })),
+ catchError(() => of({ name: miner.name, history: [] }))
+ )
+ )
+ ))
+ ).subscribe(results => {
+ results.forEach(result => {
+ this.updateChart(result.name, result.history);
+ });
+ });
+ }
+
+ stopStatsPolling(): void {
+ if (this.statsSubscription) {
+ this.statsSubscription.unsubscribe();
+ this.statsSubscription = undefined;
+ }
+ }
+
+ updateChart(minerName: string, history: HashratePoint[]): void {
+ const chartData = history.map(point => [new Date(point.timestamp).getTime(), point.hashrate]);
+ let options = this.chartOptionsMap.get(minerName);
+
+ if (!options) {
+ options = this.createChartOptions(minerName);
+ this.chartOptionsMap.set(minerName, options);
+ }
+
+ // Directly update the data property of the series
+ if (options.series && options.series.length > 0) {
+ const series = options.series[0] as Highcharts.SeriesLineOptions;
+ series.data = chartData;
+ }
+
+ // Trigger change detection by creating a new options object reference
+ // This is the correct way to make Highcharts detect updates when oneToOne is true
+ this.chartOptionsMap.set(minerName, { ...options });
+
+ // Toggle updateFlag to force re-render
+ this.updateFlag = !this.updateFlag;
+ }
+
+ createChartOptions(minerName: string): Highcharts.Options {
+ return {
+ chart: { type: 'spline' },
+ title: { text: `${minerName} Hashrate` },
+ xAxis: { type: 'datetime', title: { text: 'Time' } },
+ yAxis: { title: { text: 'Hashrate (H/s)' }, min: 0 },
+ series: [{ name: 'Hashrate', type: 'line', data: [] }],
+ credits: { enabled: false },
+ };
+ }
+
private updateWhitelistPaths() {
const paths = new Set();
this.installedMiners.forEach(miner => {