feat: Implement admin panel for miner management with setup wizard
This commit is contained in:
parent
816f860b73
commit
0d531032bf
10 changed files with 331 additions and 45 deletions
|
|
@ -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
22
ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
ui/src/app/admin.component.css
Normal file
59
ui/src/app/admin.component.css
Normal 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;
|
||||
}
|
||||
97
ui/src/app/admin.component.html
Normal file
97
ui/src/app/admin.component.html
Normal 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>
|
||||
}
|
||||
130
ui/src/app/admin.component.ts
Normal file
130
ui/src/app/admin.component.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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!');
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue