+
+
+ {{ formatHashrate(currentHashrate()) }}
+ {{ getHashrateUnit(currentHashrate()) }}
+
+
+ @if (minerCount() > 1) {
+ Total Hashrate ({{ minerCount() }} workers)
+ } @else {
+ Current Hashrate
+ }
+
+
+ Peak: {{ formatHashrate(peakHashrate()) }} {{ getHashrateUnit(peakHashrate()) }}
+
-
-
-
-
-
-
-
-
+
+
+
+
{{ acceptedShares() }}
+
Accepted
+
+
0">
+
+
{{ rejectedShares() }}
+
Rejected
+
+
+
+
{{ formatUptime(uptime()) }}
+
Uptime
+
+
+
+
{{ poolName() }}
+
Pool ({{ poolPing() }}ms)
+
+
+
+ @if (minerCount() > 1) {
+
+
+
+
+
+
+ | Worker |
+ Hashrate |
+ Shares |
+ Efficiency |
+ Pool |
+ Uptime |
+ |
+
+
+
+ @for (worker of workers(); track worker.name) {
+
+ |
+
+
+ {{ worker.name }}
+ {{ worker.algorithm }}
+
+ |
+
+
+
+
+ {{ formatHashrate(worker.hashrate) }} {{ getHashrateUnit(worker.hashrate) }}
+
+
+ |
+
+ {{ worker.shares }}
+ @if (worker.rejected > 0) {
+ ({{ worker.rejected }} rej)
+ }
+ |
+
+ = 99"
+ [class.warning]="getEfficiency(worker) < 99 && getEfficiency(worker) >= 95"
+ [class.bad]="getEfficiency(worker) < 95">
+ {{ getEfficiency(worker) | number:'1.1-1' }}%
+
+ |
+ {{ worker.pool }} |
+ {{ formatUptime(worker.uptime) }} |
+
+
+
+
+ |
+
+ }
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {{ minerName() }}
+
+ {{ algorithm() }}
+ Diff: {{ difficulty() | number }}
+
+
+ @if (minerCount() === 1) {
+
+
+ Stop Mining
+
+ }
+
+
+
+
+
+
+
+
Hashrate
+
+ - 10s Average
- {{ stats()?.hashrate?.total[0] | number:'1.0-2' }} H/s
+ - 60s Average
- {{ stats()?.hashrate?.total[1] | number:'1.0-2' }} H/s
+ - 15m Average
- {{ stats()?.hashrate?.total[2] | number:'1.0-2' }} H/s
+
+
+
+
Shares
+
+ - Good Shares
- {{ stats()?.results?.shares_good }}
+ - Total Shares
- {{ stats()?.results?.shares_total }}
+ - Avg Time
- {{ stats()?.results?.avg_time }}s
+ - Total Hashes
- {{ stats()?.results?.hashes_total | number }}
+
+
+
+
Connection
+
+ - Pool
- {{ stats()?.connection?.pool }}
+ - IP
- {{ stats()?.connection?.ip }}
+ - Ping
- {{ stats()?.connection?.ping }}ms
+ - Difficulty
- {{ stats()?.connection?.diff | number }}
+
+
+
+
System
+
+ - CPU
- {{ stats()?.cpu?.brand }}
+ - Threads
- {{ stats()?.cpu?.threads }}
+ - Algorithm
- {{ stats()?.algo }}
+ - Version
- {{ stats()?.version }}
+
+
+
+
+
} @else {
-
-
No miners running.
+
+
+
+
+
+
No Miners Running
+
Select a profile and start mining to see real-time statistics.
+
+ @if (state().profiles.length > 0) {
+
+
+ @for (profile of state().profiles; track profile.id) {
+ {{ profile.name }} ({{ profile.minerType }})
+ }
+
+
+
+ Start Mining
+
+
+ } @else {
+
No profiles configured. Create one in the Profiles section.
+ }
}
diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts
index 3ad89f2..b46d2db 100644
--- a/ui/src/app/dashboard.component.ts
+++ b/ui/src/app/dashboard.component.ts
@@ -1,7 +1,6 @@
-import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
+import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { HttpErrorResponse } from '@angular/common/http';
import { MinerService } from './miner.service';
import { ChartComponent } from './chart.component';
@@ -14,13 +13,30 @@ 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';
import '@awesome.me/webawesome/dist/components/select/select.js';
-import {StatsBarComponent} from './stats-bar.component';
+import '@awesome.me/webawesome/dist/components/badge/badge.js';
+import '@awesome.me/webawesome/dist/components/details/details.js';
+import '@awesome.me/webawesome/dist/components/tab-group/tab-group.js';
+import '@awesome.me/webawesome/dist/components/tab/tab.js';
+import '@awesome.me/webawesome/dist/components/tab-panel/tab-panel.js';
+
+// Worker stats interface
+export interface WorkerStats {
+ name: string;
+ hashrate: number;
+ shares: number;
+ rejected: number;
+ uptime: number;
+ pool: string;
+ algorithm: string;
+ cpu?: string;
+ threads?: number;
+}
@Component({
selector: 'snider-mining-dashboard',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
- imports: [CommonModule, FormsModule, ChartComponent, StatsBarComponent], // Add to imports
+ imports: [CommonModule, FormsModule, ChartComponent],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css']
})
@@ -28,4 +44,228 @@ export class MiningDashboardComponent {
minerService = inject(MinerService);
state = this.minerService.state;
error = signal
(null);
+ selectedProfileId = signal(null);
+ selectedMinerName = signal(null); // For individual miner view
+
+ // All running miners
+ runningMiners = computed(() => this.state().runningMiners);
+
+ // Worker stats for table display
+ workers = computed(() => {
+ return this.runningMiners().map(miner => {
+ const stats = miner.full_stats;
+ return {
+ name: miner.name,
+ hashrate: stats?.hashrate?.total?.[0] || 0,
+ shares: stats?.results?.shares_good || 0,
+ rejected: (stats?.results?.shares_total || 0) - (stats?.results?.shares_good || 0),
+ uptime: stats?.uptime || 0,
+ pool: stats?.connection?.pool?.split(':')[0] || 'N/A',
+ algorithm: stats?.algo || '',
+ cpu: stats?.cpu?.brand,
+ threads: stats?.cpu?.threads
+ };
+ });
+ });
+
+ // Aggregate stats across all miners
+ totalHashrate = computed(() => {
+ return this.workers().reduce((sum, w) => sum + w.hashrate, 0);
+ });
+
+ totalShares = computed(() => {
+ return this.workers().reduce((sum, w) => sum + w.shares, 0);
+ });
+
+ totalRejected = computed(() => {
+ return this.workers().reduce((sum, w) => sum + w.rejected, 0);
+ });
+
+ minerCount = computed(() => this.runningMiners().length);
+
+ // For single miner view (when selected)
+ selectedMiner = computed(() => {
+ const name = this.selectedMinerName();
+ if (!name) return null;
+ return this.runningMiners().find(m => m.name === name) || null;
+ });
+
+ // Stats for selected miner or first miner (for backward compatibility)
+ stats = computed(() => {
+ const selected = this.selectedMiner();
+ if (selected) return selected.full_stats;
+ const miners = this.runningMiners();
+ return miners.length > 0 ? miners[0].full_stats : null;
+ });
+
+ currentHashrate = computed(() => {
+ // Show total hashrate in overview mode
+ return this.totalHashrate();
+ });
+
+ peakHashrate = computed(() => {
+ // Sum of all peak hashrates
+ return this.runningMiners().reduce((sum, m) => sum + (m.full_stats?.hashrate?.highest || 0), 0);
+ });
+
+ acceptedShares = computed(() => {
+ return this.totalShares();
+ });
+
+ rejectedShares = computed(() => {
+ return this.totalRejected();
+ });
+
+ uptime = computed(() => {
+ // Show max uptime across all miners
+ return Math.max(...this.workers().map(w => w.uptime), 0);
+ });
+
+ poolName = computed(() => {
+ const pools = [...new Set(this.workers().map(w => w.pool).filter(p => p && p !== 'N/A'))];
+ if (pools.length === 0) return 'Not connected';
+ if (pools.length === 1) return pools[0];
+ return `${pools.length} pools`;
+ });
+
+ poolPing = computed(() => {
+ const pings = this.runningMiners()
+ .map(m => m.full_stats?.connection?.ping || 0)
+ .filter(p => p > 0);
+ if (pings.length === 0) return 0;
+ return Math.round(pings.reduce((a, b) => a + b, 0) / pings.length);
+ });
+
+ minerName = computed(() => {
+ const count = this.minerCount();
+ if (count === 0) return '';
+ if (count === 1) return this.runningMiners()[0].name;
+ return `${count} workers`;
+ });
+
+ algorithm = computed(() => {
+ const algos = [...new Set(this.workers().map(w => w.algorithm).filter(Boolean))];
+ if (algos.length === 0) return '';
+ if (algos.length === 1) return algos[0];
+ return algos.join(', ');
+ });
+
+ difficulty = computed(() => {
+ // Sum of difficulties (for aggregate view)
+ return this.runningMiners().reduce((sum, m) => sum + (m.full_stats?.connection?.diff || 0), 0);
+ });
+
+ // Format hashrate for display (e.g., 12345 -> "12.35")
+ formatHashrate(hashrate: number): string {
+ if (hashrate >= 1000000000) {
+ return (hashrate / 1000000000).toFixed(2);
+ } else if (hashrate >= 1000000) {
+ return (hashrate / 1000000).toFixed(2);
+ } else if (hashrate >= 1000) {
+ return (hashrate / 1000).toFixed(2);
+ }
+ return hashrate.toFixed(0);
+ }
+
+ // Get hashrate unit
+ getHashrateUnit(hashrate: number): string {
+ if (hashrate >= 1000000000) {
+ return 'GH/s';
+ } else if (hashrate >= 1000000) {
+ return 'MH/s';
+ } else if (hashrate >= 1000) {
+ return 'kH/s';
+ }
+ return 'H/s';
+ }
+
+ // Format uptime to human readable
+ formatUptime(seconds: number): string {
+ if (seconds < 60) {
+ return `${seconds}s`;
+ } else if (seconds < 3600) {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}m ${secs}s`;
+ } else {
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ return `${hours}h ${mins}m`;
+ }
+ }
+
+ // Profile selection
+ onProfileSelect(event: Event) {
+ const select = event.target as HTMLSelectElement;
+ this.selectedProfileId.set(select.value);
+ }
+
+ // Start mining with selected profile
+ startMining() {
+ const profileId = this.selectedProfileId();
+ if (profileId) {
+ this.minerService.startMiner(profileId).subscribe({
+ error: (err) => {
+ this.error.set(err.error?.error || 'Failed to start miner');
+ }
+ });
+ }
+ }
+
+ // Stop the running miner
+ stopMiner() {
+ const minerName = this.minerName();
+ if (minerName) {
+ this.minerService.stopMiner(minerName).subscribe({
+ error: (err) => {
+ this.error.set(err.error?.error || 'Failed to stop miner');
+ }
+ });
+ }
+ }
+
+ // Stop a specific worker
+ stopWorker(name: string) {
+ this.minerService.stopMiner(name).subscribe({
+ error: (err) => {
+ this.error.set(err.error?.error || `Failed to stop ${name}`);
+ }
+ });
+ }
+
+ // Stop all workers
+ stopAllWorkers() {
+ const workers = this.workers();
+ workers.forEach(w => {
+ this.minerService.stopMiner(w.name).subscribe({
+ error: (err) => {
+ console.error(`Failed to stop ${w.name}:`, err);
+ }
+ });
+ });
+ }
+
+ // Select a specific miner for detailed view
+ selectWorker(name: string) {
+ this.selectedMinerName.set(name);
+ }
+
+ // Clear miner selection (go back to overview)
+ clearSelection() {
+ this.selectedMinerName.set(null);
+ }
+
+ // Get hashrate percentage for a worker (for bar visualization)
+ getHashratePercent(worker: WorkerStats): number {
+ const total = this.totalHashrate();
+ if (total === 0) return 0;
+ return (worker.hashrate / total) * 100;
+ }
+
+ // Get efficiency (accepted / total shares)
+ getEfficiency(worker: WorkerStats): number {
+ const total = worker.shares + worker.rejected;
+ if (total === 0) return 100;
+ return (worker.shares / total) * 100;
+ }
}
diff --git a/ui/src/styles.css b/ui/src/styles.css
index ccf8cd8..aa383ff 100644
--- a/ui/src/styles.css
+++ b/ui/src/styles.css
@@ -1,4 +1,123 @@
-/* You can add global styles to this file, and also import other style files */
+/* Web Awesome Core Styles */
@import "@awesome.me/webawesome/dist/styles/webawesome.css";
@import "@awesome.me/webawesome/dist/styles/themes/awesome.css";
@import "@awesome.me/webawesome/dist/styles/native.css";
+
+/* Mining Dashboard Theme */
+:root {
+ /* Custom semantic colors for mining context */
+ --mining-hashrate-color: var(--wa-color-primary-600);
+ --mining-success-color: var(--wa-color-success-600);
+ --mining-warning-color: var(--wa-color-warning-600);
+ --mining-danger-color: var(--wa-color-danger-600);
+
+ /* Card and surface colors */
+ --surface-color: var(--wa-color-neutral-50);
+ --surface-border: var(--wa-color-neutral-200);
+ --surface-hover: var(--wa-color-neutral-100);
+
+ /* Typography */
+ --heading-color: var(--wa-color-neutral-900);
+ --text-color: var(--wa-color-neutral-700);
+ --text-muted: var(--wa-color-neutral-500);
+
+ /* Monospace for stats */
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
+}
+
+/* Dark mode overrides */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --surface-color: var(--wa-color-neutral-800);
+ --surface-border: var(--wa-color-neutral-700);
+ --surface-hover: var(--wa-color-neutral-700);
+ --heading-color: var(--wa-color-neutral-100);
+ --text-color: var(--wa-color-neutral-300);
+ --text-muted: var(--wa-color-neutral-500);
+ }
+}
+
+/* Base styles */
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ font-family: var(--wa-font-sans, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
+ background: var(--wa-color-neutral-100);
+ color: var(--text-color);
+}
+
+@media (prefers-color-scheme: dark) {
+ html, body {
+ background: var(--wa-color-neutral-900);
+ }
+}
+
+/* Tabular numbers for stats */
+.tabular-nums {
+ font-variant-numeric: tabular-nums;
+}
+
+/* Monospace for technical values */
+.mono {
+ font-family: var(--font-mono);
+}
+
+/* Common card styling */
+.card-surface {
+ background: var(--surface-color);
+ border: 1px solid var(--surface-border);
+ border-radius: 8px;
+}
+
+/* Smooth transitions */
+* {
+ transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform;
+ transition-duration: 150ms;
+ transition-timing-function: ease-in-out;
+}
+
+/* Disable transitions for elements that shouldn't animate */
+.no-transition,
+.no-transition * {
+ transition: none !important;
+}
+
+/* Focus styles for accessibility */
+:focus-visible {
+ outline: 2px solid var(--wa-color-primary-500);
+ outline-offset: 2px;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--wa-color-neutral-100);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--wa-color-neutral-400);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--wa-color-neutral-500);
+}
+
+@media (prefers-color-scheme: dark) {
+ ::-webkit-scrollbar-track {
+ background: var(--wa-color-neutral-800);
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: var(--wa-color-neutral-600);
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--wa-color-neutral-500);
+ }
+}
diff --git a/ui/test-results/.last-run.json b/ui/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/ui/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file