feat: Enhance dashboard layout with responsive stats bar and chart integration

This commit is contained in:
Snider 2025-12-11 16:04:17 +00:00
parent 7d6e6e9c42
commit 9dbcf7885c
8 changed files with 194 additions and 199 deletions

View file

@ -66,7 +66,7 @@ const docTemplate = `{
},
"/miners": {
"get": {
"description": "Get a list of all running miners",
"description": "Get a list of all running miners, including their full stats.",
"produces": [
"application/json"
],
@ -79,9 +79,7 @@ const docTemplate = `{
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.XMRigMiner"
}
"items": {}
}
}
}

View file

@ -60,7 +60,7 @@
},
"/miners": {
"get": {
"description": "Get a list of all running miners",
"description": "Get a list of all running miners, including their full stats.",
"produces": [
"application/json"
],
@ -73,9 +73,7 @@
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.XMRigMiner"
}
"items": {}
}
}
}

View file

@ -318,15 +318,14 @@ paths:
- system
/miners:
get:
description: Get a list of all running miners
description: Get a list of all running miners, including their full stats.
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/mining.XMRigMiner'
items: {}
type: array
summary: List all running miners
tags:

View file

@ -1,74 +1,56 @@
.admin-panel {
padding: 1rem;
/* Set up the container */
.dashboard-view {
container-type: inline-size;
container-name: dashboard;
height: 100%;
display: flex; /* Ensure it fills height */
flex-direction: column;
}
.admin-title {
margin-top: 0;
}
.miner-list, .path-list {
margin-bottom: 1.5rem;
}
.miner-item, .path-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.path-list ul {
list-style-type: none;
padding: 0;
}
.path-list li {
background-color: #f5f5f5;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
font-family: monospace;
}
.dashboard-summary {
padding: 1rem;
}
.miner-summary-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.miner-name {
font-weight: bold;
}
.start-buttons {
display: flex;
gap: 0.5rem;
}
.start-options {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
margin-top: 0.5rem;
.dashboard-content {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
flex-grow: 1; /* Allow content to grow */
}
.dashboard-charts {
padding: 1rem;
/* Default (mobile/narrow) layout */
.stats-list-container {
display: none; /* Hide the detailed list by default */
}
.miner-chart-item {
margin-bottom: 1.5rem;
.stats-bar-container {
display: block; /* Show the bar by default */
}
.chart-container {
flex-grow: 1;
min-height: 200px; /* Ensure chart has a minimum height */
}
/* Wide layout using container queries */
@container dashboard (min-width: 768px) {
.dashboard-content {
display: grid;
grid-template-columns: 2fr 1fr; /* 2/3 for chart, 1/3 for stats */
gap: 1.5rem;
align-items: stretch; /* Stretch items to fill height */
}
.stats-bar-container {
display: none; /* Hide the bar in wide view */
}
.stats-list-container {
display: block; /* Show the detailed list in wide view */
grid-column: 2;
grid-row: 1;
}
.chart-container {
grid-column: 1;
grid-row: 1;
}
}
.centered-container {
@ -78,10 +60,7 @@
justify-content: center;
padding: 2rem;
text-align: center;
}
.button-spinner {
font-size: 1rem;
height: 100%;
}
.card-error {

View file

@ -9,11 +9,22 @@
</wa-card>
}
<!-- Consolidated Chart for All Running Miners -->
@if (state().runningMiners.length > 0) {
<div class="dashboard-charts">
<snider-mining-stats-bar [stats]="state().runningMiners[0]?.full_stats"></snider-mining-stats-bar>
<snider-mining-chart></snider-mining-chart>
<div class="dashboard-content">
<!-- Stats Bar for small containers -->
<div class="stats-bar-container">
<snider-mining-stats-bar [stats]="state().runningMiners[0]?.full_stats" mode="bar"></snider-mining-stats-bar>
</div>
<!-- Chart -->
<div class="chart-container">
<snider-mining-chart></snider-mining-chart>
</div>
<!-- Stats List for large containers -->
<div class="stats-list-container">
<snider-mining-stats-bar [stats]="state().runningMiners[0]?.full_stats" mode="list"></snider-mining-stats-bar>
</div>
</div>
} @else {
<div class="centered-container">

View file

@ -4,9 +4,6 @@ import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { MinerService } from './miner.service';
import { ChartComponent } from './chart.component';
import { ProfileListComponent } from './profile-list.component';
import { ProfileCreateComponent } from './profile-create.component';
import { StatsBarComponent } from './stats-bar.component';
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
@ -17,84 +14,18 @@ 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';
@Component({
selector: 'snider-mining-dashboard',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, FormsModule, ChartComponent, ProfileListComponent, ProfileCreateComponent, StatsBarComponent],
imports: [CommonModule, FormsModule, ChartComponent, StatsBarComponent], // Add to imports
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css']
})
export class MiningDashboardComponent {
minerService = inject(MinerService);
state = this.minerService.state;
actionInProgress = signal<string | null>(null);
error = signal<string | null>(null);
showProfileManager = signal(false);
// Use a map to track the selected profile for each miner type
selectedProfileIds = signal<Map<string, string>>(new Map());
handleProfileSelection(minerType: string, event: Event) {
const selectedValue = (event.target as HTMLSelectElement).value;
this.selectedProfileIds.update(m => m.set(minerType, selectedValue));
}
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress.set(null);
if (err.error && err.error.error) {
this.error.set(`${defaultMessage}: ${err.error.error}`);
} else if (typeof err.error === 'string' && err.error.length < 200) {
this.error.set(`${defaultMessage}: ${err.error}`);
} else {
this.error.set(`${defaultMessage}. Please check the console for details.`);
}
}
startMiner(minerType: string): void {
const profileId = this.selectedProfileIds().get(minerType);
if (!profileId) {
this.error.set('Please select a profile to start.');
return;
}
this.actionInProgress.set(`start-${profileId}`);
this.error.set(null);
this.minerService.startMiner(profileId).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to start miner for profile ${profileId}`);
}
});
}
stopMiner(miner: any): void {
const runningInstance = this.getRunningMinerInstance(miner);
if (!runningInstance) {
this.error.set("Cannot stop a miner that is not running.");
return;
}
this.actionInProgress.set(`stop-${miner.type}`);
this.error.set(null);
this.minerService.stopMiner(runningInstance.name).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to stop ${runningInstance.name}`);
}
});
}
getRunningMinerInstance(miner: any): any {
return this.state().runningMiners.find((m: any) => m.name.startsWith(miner.type));
}
isMinerRunning(miner: any): boolean {
return !!this.getRunningMinerInstance(miner);
}
toggleProfileManager() {
this.showProfileManager.set(!this.showProfileManager());
}
}

View file

@ -8,44 +8,112 @@ import { CommonModule } from '@angular/common';
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
@if(stats) {
<div class="stats-bar">
<div class="stat-item">
<span class="label">Hashrate:</span>
<span class="value">{{ stats.hashrate?.total[0] | number:'1.0-2' }} H/s</span>
@if (mode === 'list') {
<div class="stats-container list-mode">
<dl class="stats-dl">
<!-- General -->
<dt>Algorithm</dt><dd>{{ stats.algo }}</dd>
<dt>Uptime</dt><dd>{{ stats.uptime }}s</dd>
<dt>Version</dt><dd>{{ stats.version }}</dd>
<!-- Hashrate -->
<dt>Hashrate (10s)</dt><dd>{{ stats.hashrate?.total[0] | number:'1.0-2' }} H/s</dd>
<dt>Hashrate (60s)</dt><dd>{{ stats.hashrate?.total[1] | number:'1.0-2' }} H/s</dd>
<dt>Hashrate (15m)</dt><dd>{{ stats.hashrate?.total[2] | number:'1.0-2' }} H/s</dd>
<dt>Highest Hashrate</dt><dd>{{ stats.hashrate?.highest | number:'1.0-2' }} H/s</dd>
<!-- Results -->
<dt>Good Shares</dt><dd>{{ stats.results?.shares_good }}</dd>
<dt>Total Shares</dt><dd>{{ stats.results?.shares_total }}</dd>
<dt>Avg. Time</dt><dd>{{ stats.results?.avg_time }}s</dd>
<dt>Total Hashes</dt><dd>{{ stats.results?.hashes_total | number }}</dd>
<!-- Connection -->
<dt>Pool</dt><dd>{{ stats.connection?.pool }}</dd>
<dt>Pool Uptime</dt><dd>{{ stats.connection?.uptime }}s</dd>
<dt>Pool Ping</dt><dd>{{ stats.connection?.ping }}ms</dd>
<dt>Current Difficulty</dt><dd>{{ stats.connection?.diff | number }}</dd>
<dt>Accepted Shares</dt><dd>{{ stats.connection?.accepted }}</dd>
<dt>Rejected Shares</dt><dd>{{ stats.connection?.rejected }}</dd>
<!-- CPU -->
<dt>CPU Brand</dt><dd>{{ stats.cpu?.brand }}</dd>
<dt>CPU Cores/Threads</dt><dd>{{ stats.cpu?.cores }} / {{ stats.cpu?.threads }}</dd>
</dl>
</div>
<div class="stat-item">
<span class="label">Algorithm:</span>
<span class="value">{{ stats.algo }}</span>
} @else {
<div class="stats-bar bar-mode">
<div class="stat-item">
<span class="label">Hashrate:</span>
<span class="value">{{ stats.hashrate?.total[0] | number:'1.0-2' }} H/s</span>
</div>
<div class="stat-item">
<span class="label">Algorithm:</span>
<span class="value">{{ stats.algo }}</span>
</div>
<div class="stat-item">
<span class="label">Difficulty:</span>
<span class="value">{{ stats.connection?.diff | number }}</span>
</div>
<div class="stat-item">
<span class="label">Accepted:</span>
<span class="value">{{ stats.results?.shares_good }}</span>
</div>
<div class="stat-item">
<span class="label">Rejected:</span>
<span class="value">{{ stats.connection?.rejected }}</span>
</div>
<div class="stat-item">
<span class="label">Avg Time:</span>
<span class="value">{{ stats.results?.avg_time | number }}s</span>
</div>
<div class="stat-item">
<span class="label">Uptime:</span>
<span class="value">{{ stats.uptime | number }}s</span>
</div>
<div class="stat-item">
<span class="label">Pool Uptime:</span>
<span class="value">{{ stats.connection?.uptime | number }}s</span>
</div>
</div>
<div class="stat-item">
<span class="label">Difficulty:</span>
<span class="value">{{ stats.connection?.diff | number }}</span>
</div>
<div class="stat-item">
<span class="label">Accepted:</span>
<span class="value">{{ stats.results?.shares_good }}</span>
</div>
<div class="stat-item">
<span class="label">Rejected:</span>
<span class="value">{{ stats.connection?.rejected }}</span>
</div>
<div class="stat-item">
<span class="label">Avg Time:</span>
<span class="value">{{ stats.results?.avg_time | number }}s</span>
</div>
<div class="stat-item">
<span class="label">Uptime:</span>
<span class="value">{{ stats.uptime | number }}s</span>
</div>
<div class="stat-item">
<span class="label">Pool Uptime:</span>
<span class="value">{{ stats.connection?.uptime | number }}s</span>
</div>
</div>
}
}
`,
styles: [`
.stats-bar {
/* List Mode Styles */
.stats-container.list-mode {
height: 100%;
max-height: 400px;
overflow-y: auto;
padding-right: 1rem;
background-color: #f9f9f9;
border: 1px solid #eee;
border-radius: 8px;
}
.list-mode .stats-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
padding: 1rem;
}
.list-mode dt {
font-weight: bold;
color: #555;
grid-column: 1;
white-space: nowrap;
}
.list-mode dd {
margin: 0;
grid-column: 2;
text-align: right;
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Bar Mode Styles */
.stats-bar.bar-mode {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
@ -55,7 +123,7 @@ import { CommonModule } from '@angular/common';
border-radius: 8px;
margin-bottom: 1rem;
}
.stat-item {
.bar-mode .stat-item {
display: flex;
flex-direction: column;
align-items: center;
@ -64,12 +132,12 @@ import { CommonModule } from '@angular/common';
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.label {
.bar-mode .label {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.25rem;
}
.value {
.bar-mode .value {
font-weight: bold;
font-size: 1.1rem;
}
@ -77,4 +145,5 @@ import { CommonModule } from '@angular/common';
})
export class StatsBarComponent {
@Input() stats: any;
@Input() mode: 'bar' | 'list' = 'bar';
}

View file

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<title>Snider Mining</title>
@ -8,10 +8,10 @@
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 text-gray-800">
<body class="bg-gray-100 text-gray-800 h-full">
<div class="flex flex-col h-screen">
<!-- Top Bar -->
<header class="flex items-center justify-between h-16 px-6 bg-white border-b">
<header class="flex items-center justify-between h-16 px-6 bg-white border-b flex-shrink-0">
<div>
<h2 class="text-xl font-semibold">Mining Dashboard (Dev View)</h2>
</div>
@ -22,10 +22,23 @@
<!-- Main Content Area -->
<main class="flex-1 p-6 overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-dashboard></snider-mining-dashboard>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 h-full">
<!-- Wide Dashboard Instance -->
<div class="bg-white shadow-md rounded-lg p-4 lg:col-span-2 flex flex-col">
<h3 class="font-bold mb-2 flex-shrink-0">Wide Dashboard</h3>
<div class="flex-grow">
<snider-mining-dashboard></snider-mining-dashboard>
</div>
</div>
<!-- Standard Dashboard Instance -->
<div class="bg-white shadow-md rounded-lg p-4 flex flex-col">
<h3 class="font-bold mb-2 flex-shrink-0">Standard Dashboard</h3>
<div class="flex-grow">
<snider-mining-dashboard></snider-mining-dashboard>
</div>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-admin></snider-mining-admin>
</div>
@ -35,9 +48,6 @@
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-setup-wizard></snider-mining-setup-wizard>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-chart></snider-mining-chart>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-profile-list></snider-mining-profile-list>
</div>
@ -46,7 +56,7 @@
</main>
<!-- Sticky Footer -->
<footer class="h-12 px-6 bg-white border-t flex items-center">
<footer class="h-12 px-6 bg-white border-t flex items-center flex-shrink-0">
<p class="text-sm text-gray-600">&copy; 2025 Snider. All rights reserved.</p>
</footer>
</div>