From 8f888a3749fabd836639ed515f51213240d9d968 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 7 Dec 2025 18:31:27 +0000 Subject: [PATCH] feat: Refactor dashboard layout and integrate admin panel functionality --- pkg/mining/service.go | 35 +++-- ui/src/app/app.css | 64 +++++++-- ui/src/app/app.html | 328 ++++++++++++++++++++++-------------------- ui/src/app/app.ts | 114 ++++++++++++--- 4 files changed, 336 insertions(+), 205 deletions(-) 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) { + Setup Required + } @else { + Mining Control + } +
+ @if (!needsSetup) { + + + + }
- } - - @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 @@ } - -
-
- - Mining Control -
- - - -
+ + @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 (actionInProgress === 'stop-' + miner.type) { + variant="secondary" + [disabled]="actionInProgress === 'start-' + miner.type" + (click)="startMiner(miner, true)"> + @if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) { } @else { - - Stop + + Start Last Config } - } @else { -
- - @if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) { - - } @else { - - Start Last Config - } - - - - New Config - -
- } -
- - @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 => {