diff --git a/ui/angular.json b/ui/angular.json index 2378f74..4bbe612 100644 --- a/ui/angular.json +++ b/ui/angular.json @@ -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" diff --git a/ui/package-lock.json b/ui/package-lock.json index 0ef2ae2..4670658 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 2bdcd47..450610f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" } } diff --git a/ui/src/app/admin.component.css b/ui/src/app/admin.component.css new file mode 100644 index 0000000..525e947 --- /dev/null +++ b/ui/src/app/admin.component.css @@ -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; +} diff --git a/ui/src/app/admin.component.html b/ui/src/app/admin.component.html new file mode 100644 index 0000000..f6e2ed7 --- /dev/null +++ b/ui/src/app/admin.component.html @@ -0,0 +1,97 @@ + +@if (needsSetup) { +
+

To begin, please install a miner from the list below.

+

Available Miners

+
+ @for (miner of manageableMiners; track miner.name) { +
+ {{ miner.name }} + + @if (actionInProgress === 'install-' + miner.name) { + + } @else { + + Install + } + +
+ } @empty { +
+ Could not load available miners. +
+ } +
+
+} + + +@if (!needsSetup) { +
+ @if (error) { + +
+ + An Error Occurred +
+

{{ error }}

+
+ } + +

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:

+ +
+
+} diff --git a/ui/src/app/admin.component.ts b/ui/src/app/admin.component.ts new file mode 100644 index 0000000..6f064ff --- /dev/null +++ b/ui/src/app/admin.component.ts @@ -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(`${this.apiBaseUrl}/miners/available`), + info: this.http.get(`${this.apiBaseUrl}/info`).pipe(catchError(() => of({}))) // Gracefully handle info error + }).pipe( + map(({ available, info }) => { + 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 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(); + 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'; + } +} diff --git a/ui/src/app/app.css b/ui/src/app/app.css index a00e46e..97043a3 100644 --- a/ui/src/app/app.css +++ b/ui/src/app/app.css @@ -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; } diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index fcac68b..05edeab 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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) { diff --git a/ui/src/index.html b/ui/src/index.html index 2ef73fc..d8ff6c8 100644 --- a/ui/src/index.html +++ b/ui/src/index.html @@ -8,6 +8,7 @@ - + + diff --git a/ui/src/main.ts b/ui/src/main.ts index cac705b..329ca46 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -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!'); })();