feat: Refactor dashboard layout and integrate admin panel functionality

This commit is contained in:
Snider 2025-12-07 18:31:27 +00:00
parent 0d531032bf
commit 8f888a3749
4 changed files with 336 additions and 205 deletions

View file

@ -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")

View file

@ -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;
}

View file

@ -1,25 +1,42 @@
<div class="mining-dashboard">
<!-- Initial Loading State -->
@if (systemInfo === null && !needsSetup) {
<div class="centered-container">
<wa-card class="card-overview">
<div slot="header">Loading...</div>
<wa-spinner style="font-size: 3rem; margin-top: 1rem;"></wa-spinner>
</wa-card>
<!-- The main container card that is ALWAYS present -->
<wa-card class="card-overview">
<div slot="header" class="card-header">
<div class="header-title">
<wa-icon name="cpu" style="font-size: 1.5rem;"></wa-icon>
<!-- Title changes based on state -->
@if (needsSetup) {
<span>Setup Required</span>
} @else {
<span>Mining Control</span>
}
</div>
@if (!needsSetup) {
<wa-button variant="neutral" appearance="plain" (click)="toggleAdminPanel()">
<wa-icon name="gear" label="Admin Panel"></wa-icon>
</wa-button>
}
</div>
}
<!-- Setup Wizard: Shown when needsSetup is true -->
@if (needsSetup) {
<div class="centered-container">
<wa-card class="card-overview">
<div slot="header">
<wa-icon name="wand-magic-sparkles" style="font-size: 2rem;"></wa-icon>
Welcome! Let's Get Started
</div>
<!-- Initial Loading State -->
@if (systemInfo === null && !needsSetup) {
<div class="centered-container">
<wa-spinner style="font-size: 3rem; margin-top: 1rem;"></wa-spinner>
</div>
} @else if (!apiAvailable) {
<!-- API Not Available State -->
<div class="centered-container">
<p>API Not Available. Please ensure the mining service is running.</p>
<wa-button (click)="checkSystemState()">
<wa-icon name="arrow-clockwise" slot="prefix"></wa-icon>
Retry
</wa-button>
</div>
} @else if (apiAvailable && needsSetup) {
<!-- Setup Wizard Content (re-integrated) -->
<div class="setup-wizard">
<p>To begin, please install a miner from the list below.</p>
<h4>Available Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
@ -44,20 +61,9 @@
</div>
}
</div>
<div slot="footer">
<wa-button (click)="checkSystemState()">
<wa-icon name="arrow-clockwise" slot="prefix"></wa-icon>
Refresh Status
</wa-button>
</div>
</wa-card>
</div>
}
<!-- Main Content: API is Available and initial load is complete -->
@if (systemInfo !== null && apiAvailable && !needsSetup) {
<div>
</div>
} @else if (apiAvailable && !needsSetup) {
<!-- Main Content (when not in setup mode) -->
@if (error) {
<wa-card class="card-error">
<div slot="header">
@ -68,147 +74,155 @@
</wa-card>
}
<wa-card class="card-overview">
<div slot="header" class="card-header">
<div class="header-title">
<wa-icon name="cpu" style="font-size: 1.5rem;"></wa-icon>
Mining Control
</div>
<wa-button variant="neutral" appearance="plain" (click)="toggleAdminPanel()">
<wa-icon name="gear" label="Admin Panel"></wa-icon>
</wa-button>
</div>
<!-- Admin Panel Content (re-integrated) -->
@if (showAdminPanel) {
<div class="admin-panel">
<h3 class="admin-title">Admin Panel</h3>
<!-- Admin Panel -->
@if (showAdminPanel) {
<div class="admin-panel">
<h3 class="admin-title">Admin Panel</h3>
<h4>Manage Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
@if (miner.is_installed) {
<wa-button
variant="danger"
size="small"
[disabled]="actionInProgress === 'uninstall-' + miner.name"
(click)="uninstallMiner(miner.name)">
@if (actionInProgress === 'uninstall-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="trash" slot="prefix"></wa-icon>
Uninstall
}
</wa-button>
} @else {
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
}
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
<h4>Antivirus Whitelist Paths</h4>
<div class="path-list">
<p>To prevent antivirus software from interfering, please add the following paths to your exclusion list:</p>
<ul>
@for (path of whitelistPaths; track path) {
<li><code>{{ path }}</code></li>
}
</ul>
</div>
</div>
}
<!-- Miner Control List -->
@if (installedMiners.length > 0) {
<h4>Manage Miners</h4>
<div class="miner-list">
@for (miner of installedMiners; track miner.path) {
<div class="miner-item-container">
<div class="miner-item">
<span class="miner-name">
{{ miner.type }}
<wa-tooltip>
<div slot="content">Version: {{ miner.version }}</div>
<wa-icon name="info-circle"></wa-icon>
</wa-tooltip>
</span>
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
@if (miner.is_installed) {
<wa-button
variant="danger"
size="small"
[disabled]="actionInProgress === 'uninstall-' + miner.name"
(click)="uninstallMiner(miner.name)">
@if (actionInProgress === 'uninstall-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="trash" slot="prefix"></wa-icon>
Uninstall
}
</wa-button>
} @else {
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
}
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
@if (isMinerRunning(miner)) {
<h4>Antivirus Whitelist Paths</h4>
<div class="path-list">
<p>To prevent antivirus software from interfering, please add the following paths to your exclusion list:</p>
<ul>
@for (path of whitelistPaths; track path) {
<li><code>{{ path }}</code></li>
} @empty {
<li>No paths to display. Install a miner to see required paths.</li>
}
</ul>
</div>
</div>
} @else { <!-- Only show dashboard content if admin panel is not open -->
<!-- Miner Summary and Controls -->
@if (installedMiners.length > 0) {
<div class="dashboard-summary">
@for (miner of installedMiners; track miner.path) {
<div class="miner-summary-item">
<span class="miner-name">
{{ miner.type }}
<wa-tooltip>
<div slot="content">Version: {{ miner.version }}</div>
<wa-icon name="info-circle"></wa-icon>
</wa-tooltip>
</span>
@if (isMinerRunning(miner)) {
<wa-button
variant="danger"
[disabled]="actionInProgress === 'stop-' + miner.type"
(click)="stopMiner(miner)">
@if (actionInProgress === 'stop-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="stop-circle" slot="prefix"></wa-icon>
Stop
}
</wa-button>
} @else {
<div class="start-buttons">
<wa-button
variant="danger"
[disabled]="actionInProgress === 'stop-' + miner.type"
(click)="stopMiner(miner)">
@if (actionInProgress === 'stop-' + miner.type) {
variant="secondary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner, true)">
@if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="stop-circle" slot="prefix"></wa-icon>
Stop
<wa-icon name="play-circle" slot="prefix"></wa-icon>
Start Last Config
}
</wa-button>
} @else {
<div class="start-buttons">
<wa-button
variant="secondary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner, true)">
@if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="play-circle" slot="prefix"></wa-icon>
Start Last Config
}
</wa-button>
<wa-button
variant="primary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="toggleStartOptions(miner.type)">
<wa-icon name="gear" slot="prefix"></wa-icon>
New Config
</wa-button>
</div>
}
</div>
@if (showStartOptionsFor === miner.type) {
<div class="start-options">
<wa-input label="Pool Address" [(ngModel)]="poolAddress" name="poolAddress"></wa-input>
<wa-input label="Wallet Address" [(ngModel)]="walletAddress" name="walletAddress"></wa-input>
<wa-button
variant="success"
variant="primary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner)">
@if (actionInProgress === 'start-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="rocket-launch" slot="prefix"></wa-icon>
Confirm & Start
}
(click)="toggleStartOptions(miner.type)">
<wa-icon name="gear" slot="prefix"></wa-icon>
New Config
</wa-button>
</div>
}
</div>
@if (showStartOptionsFor === miner.type) {
<div class="start-options">
<wa-input label="Pool Address" [(ngModel)]="poolAddress" name="poolAddress"></wa-input>
<wa-input label="Wallet Address" [(ngModel)]="walletAddress" name="walletAddress"></wa-input>
<wa-button
variant="success"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner)">
@if (actionInProgress === 'start-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="rocket-launch" slot="prefix"></wa-icon>
Confirm & Start
}
</wa-button>
</div>
}
}
</div>
}
</wa-card>
</div>
}
<!-- Charts for Running Miners -->
<div class="dashboard-charts">
@for (miner of installedMiners; track miner.path) {
@if (isMinerRunning(miner) && chartOptionsMap.has(getRunningMinerInstance(miner).name)) {
<div class="miner-chart-item">
<highcharts-chart
[constructorType]="chartConstructor"
[options]="chartOptionsMap.get(getRunningMinerInstance(miner).name)!"
[(update)]="updateFlag"
[oneToOne]="oneToOneFlag"
class="chart"
/>
</div>
}
}
</div>
} @else {
<div class="centered-container">
<p>No miners installed. Open the Admin Panel to install one.</p>
</div>
}
}
}
</wa-card>
</div>

View file

@ -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<string, Highcharts.Options> = 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<string, InstallationDetails>(
(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<string, InstallationDetails>((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<any[]>(`${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<HashratePoint[]>(`${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<string>();
this.installedMiners.forEach(miner => {