feat: Implement admin panel for miner management with setup wizard

This commit is contained in:
Snider 2025-12-07 17:08:26 +00:00
parent 816f860b73
commit 0d531032bf
10 changed files with 331 additions and 45 deletions

View file

@ -11,7 +11,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "ngx-build-plus:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
@ -32,7 +32,8 @@
"scripts": [],
"outputHashing": "none",
"namedChunks": false,
"optimization": true
"optimization": true,
"singleBundle": true
},
"configurations": {
"production": {
@ -58,7 +59,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "ngx-build-plus:dev-server",
"configurations": {
"production": {
"buildTarget": "ui:build:production"

22
ui/package-lock.json generated
View file

@ -36,6 +36,7 @@
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"ngx-build-plus": "^20.0.0",
"typescript": "~5.9.2"
}
},
@ -644,6 +645,7 @@
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.10.tgz",
"integrity": "sha512-cW939Lr8GZjPSYfbQKIDNrUaHWmn2M+zBbERThfq5skLuY+xM60bJFv4NqBekfX6YqKLCY62ilUZlnImYIXaqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -4966,6 +4968,7 @@
"integrity": "sha512-XkgTwGhhrx+MVi2+TFO32d6Es5Uezzx7Y7B/e2ulDlj08bizxQj+9wkeLt5+bR8JWODHpEntZn/Xd5WvXnODGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@angular-devkit/core": "20.3.9",
"@angular-devkit/schematics": "20.3.9",
@ -5267,6 +5270,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -6309,6 +6313,7 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@ -8879,6 +8884,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -10398,6 +10404,21 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/ngx-build-plus": {
"version": "20.0.0",
"resolved": "https://registry.npmjs.org/ngx-build-plus/-/ngx-build-plus-20.0.0.tgz",
"integrity": "sha512-cm1ZMTACAN3DEqBt/alS84zwVGgL5HAl5Dk/wh7CPyGUBQnLaxiAhjFZ6iykxgSO3e9ebIZmDBvTC480piC1eA==",
"dev": true,
"license": "MIT",
"dependencies": {
"webpack-merge": "^6.0.0"
},
"peerDependencies": {
"@angular-devkit/build-angular": ">=20.0.0",
"@schematics/angular": ">=20.0.0",
"rxjs": ">= 6.0.0"
}
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@ -15464,6 +15485,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz",
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0",

View file

@ -50,6 +50,7 @@
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"ngx-build-plus": "^20.0.0",
"typescript": "~5.9.2"
}
}

View file

@ -0,0 +1,59 @@
:host {
display: block;
font-family: sans-serif;
width: 100%;
}
.admin-card {
width: 100%;
box-sizing: border-box;
}
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-error {
margin-bottom: 1rem;
}
.miner-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.miner-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid #e0e0e0;
}
.section-title {
margin-top: 1.5rem;
border-top: 1px solid #e0e0e0;
padding-top: 1.5rem;
}
.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;
}
.button-spinner {
font-size: 1em;
margin: 0;
}

View file

@ -0,0 +1,97 @@
<!-- Setup Wizard View -->
@if (needsSetup) {
<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) {
<div class="miner-item">
<span>{{ miner.name }}</span>
<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>
</div>
}
<!-- Standard Admin Panel View -->
@if (!needsSetup) {
<div class="admin-panel">
@if (error) {
<wa-card class="card-error">
<div slot="header">
<wa-icon name="exclamation-triangle" style="font-size: 1.5rem;"></wa-icon>
An Error Occurred
</div>
<p>{{ error }}</p>
</wa-card>
}
<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 class="section-title">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>
}

View file

@ -0,0 +1,130 @@
import { Component, OnInit, Input, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { HttpClient, HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { of, forkJoin } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
// Define interfaces for our data structures
interface InstallationDetails {
is_installed: boolean;
version: string;
path: string;
miner_binary: string;
config_path?: string;
type?: string;
}
interface AvailableMiner {
name: string;
description: string;
}
@Component({
selector: 'snider-mining-admin',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, HttpClientModule],
templateUrl: './admin.component.html',
styleUrls: ['./admin.component.css'],
})
export class MiningAdminComponent implements OnInit {
@Input() needsSetup: boolean = false; // Input to trigger setup mode
apiBaseUrl: string = 'http://localhost:9090/api/v1/mining';
error: string | null = null;
actionInProgress: string | null = null;
manageableMiners: any[] = [];
whitelistPaths: string[] = [];
constructor(private http: HttpClient) {}
ngOnInit(): void {
this.getAdminData();
}
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress = null;
if (err.error && err.error.error) {
this.error = `${defaultMessage}: ${err.error.error}`;
} else {
this.error = `${defaultMessage}. Please check the console for details.`;
}
}
getAdminData(): void {
this.error = null;
forkJoin({
available: this.http.get<AvailableMiner[]>(`${this.apiBaseUrl}/miners/available`),
info: this.http.get<any>(`${this.apiBaseUrl}/info`).pipe(catchError(() => of({}))) // Gracefully handle info error
}).pipe(
map(({ available, info }) => {
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 installedMiners = (info.installed_miners_info || []).filter((m: InstallationDetails) => m.is_installed);
this.updateWhitelistPaths(installedMiners, []);
}),
catchError(err => {
this.handleError(err, 'Could not load miner information');
return of(null);
})
).subscribe();
}
private updateWhitelistPaths(installed: InstallationDetails[], running: any[]) {
const paths = new Set<string>();
installed.forEach(miner => {
if (miner.miner_binary) paths.add(miner.miner_binary);
if (miner.config_path) paths.add(miner.config_path);
});
running.forEach(miner => {
if (miner.configPath) paths.add(miner.configPath);
});
this.whitelistPaths = Array.from(paths);
}
installMiner(minerType: string): void {
this.actionInProgress = `install-${minerType}`;
this.error = null;
this.http.post(`${this.apiBaseUrl}/miners/${minerType}/install`, {}).subscribe({
next: () => {
setTimeout(() => {
this.getAdminData();
// A simple way to signal completion is to reload the page
// so the main dashboard component re-evaluates its state.
if (this.needsSetup) {
window.location.reload();
}
}, 1000);
},
error: (err: HttpErrorResponse) => this.handleError(err, `Failed to install ${minerType}`)
});
}
uninstallMiner(minerType: string): void {
this.actionInProgress = `uninstall-${minerType}`;
this.error = null;
this.http.delete(`${this.apiBaseUrl}/miners/${minerType}/uninstall`).subscribe({
next: () => {
setTimeout(() => {
this.getAdminData();
}, 1000);
},
error: (err: HttpErrorResponse) => this.handleError(err, `Failed to uninstall ${minerType}`)
});
}
getMinerType(miner: any): string {
if (!miner.path) return 'unknown';
const parts = miner.path.split('/').filter((p: string) => p);
return parts.length > 1 ? parts[parts.length - 2] : parts[parts.length - 1] || 'unknown';
}
}

View file

@ -42,6 +42,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.miner-item-container {
@ -79,36 +80,8 @@
padding-top: 1rem;
}
.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;
}
.button-spinner {
font-size: 1em; /* Make spinner same size as button text */
font-size: 1em;
margin: 0;
}

View file

@ -36,7 +36,7 @@ interface AvailableMiner {
}
@Component({
selector: 'mde-mining-dashboard',
selector: 'snider-mining-dashboard',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, HttpClientModule, FormsModule],
@ -52,7 +52,7 @@ export class MiningDashboardElementComponent implements OnInit {
apiAvailable: boolean = true;
error: string | null = null;
showAdminPanel: boolean = false;
actionInProgress: string | null = null; // To track which miner action is running
actionInProgress: string | null = null;
systemInfo: any = null;
manageableMiners: any[] = [];
@ -73,6 +73,7 @@ export class MiningDashboardElementComponent implements OnInit {
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress = null;
if (err.error && err.error.error) {
this.error = `${defaultMessage}: ${err.error.error}`;
} else if (typeof err.error === 'string' && err.error.length < 200) {

View file

@ -8,6 +8,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<mde-mining-dashboard></mde-mining-dashboard>
<snider-mining-dashboard></snider-mining-dashboard>
<snider-mining-admin></snider-mining-admin>
</body>
</html>

View file

@ -1,18 +1,19 @@
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { MiningDashboardElementComponent } from './app/app'; // Renamed App to MiningDashboardElementComponent
import {appConfig} from './app/app.config';
import { MiningDashboardElementComponent } from './app/app';
import { MiningAdminComponent } from './app/admin.component';
import { appConfig } from './app/app.config';
(async () => {
// Bootstrap a minimal Angular application to provide
// necessary services like HttpClient to the custom element.
const app = await createApplication(appConfig);
// Define your custom element
const MiningDashboardElement = createCustomElement(MiningDashboardElementComponent, { injector: app.injector });
// Define the dashboard element as the primary application root
const DashboardElement = createCustomElement(MiningDashboardElementComponent, { injector: app.injector });
customElements.define('snider-mining-dashboard', DashboardElement);
console.log('snider-mining-dashboard custom element registered!');
// Register the custom element with the browser
customElements.define('mde-mining-dashboard', MiningDashboardElement);
console.log('mde-mining-dashboard custom element registered!');
// // Define the admin element as a separate, secondary element
const AdminElement = createCustomElement(MiningAdminComponent, { injector: app.injector });
customElements.define('snider-mining-admin', AdminElement);
console.log('snider-mining-admin custom element registered!');
})();