feat(ui): Add loading states with spinners to action buttons

Add visual feedback for async operations across UI components:
- Profile list: Start, Delete, and Save buttons show spinners during actions
- Profile create: Create Profile button shows spinner during submission
- Nodes page: Initialize Node, Add Peer, Ping, and Remove buttons show spinners

Buttons are disabled while their action is in progress to prevent
duplicate submissions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
snider 2025-12-29 22:08:01 +00:00
parent 9a781ae3f0
commit 5f3fe0deee
5 changed files with 1190 additions and 153 deletions

View file

@ -0,0 +1,980 @@
import { Component, inject, signal, OnInit, OnDestroy, WritableSignal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NodeService, Peer, NodeIdentity } from '../../node.service';
@Component({
selector: 'app-nodes',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="nodes-page">
<!-- Local Node Identity Section -->
<section class="node-identity-section">
<h2 class="section-title">
<svg class="section-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
</svg>
Local Node
</h2>
@if (nodeService.initialized()) {
<div class="identity-card">
<div class="identity-info">
<div class="identity-name">
<div class="status-dot online"></div>
<span>{{ nodeService.identity()?.name }}</span>
<span class="role-badge" [class]="nodeService.identity()?.role">
{{ nodeService.identity()?.role }}
</span>
</div>
<div class="identity-id">
<span class="label">Node ID:</span>
<code>{{ nodeService.identity()?.id }}</code>
<button class="copy-btn" (click)="copyToClipboard(nodeService.identity()?.id || '')" title="Copy ID">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<div class="identity-stats">
<div class="stat">
<span class="stat-value">{{ nodeService.peers().length }}</span>
<span class="stat-label">Peers</span>
</div>
<div class="stat">
<span class="stat-value">{{ nodeService.onlinePeers().length }}</span>
<span class="stat-label">Online</span>
</div>
</div>
</div>
} @else {
<div class="init-card">
<h3>Initialize Node Identity</h3>
<p>Set up your node to enable P2P communication with remote mining rigs.</p>
<form class="init-form" (ngSubmit)="initializeNode()">
<div class="form-group">
<label for="nodeName">Node Name</label>
<input
id="nodeName"
type="text"
[(ngModel)]="newNodeName"
name="nodeName"
placeholder="e.g., control-center"
required>
</div>
<div class="form-group">
<label for="nodeRole">Role</label>
<select id="nodeRole" [(ngModel)]="newNodeRole" name="nodeRole">
<option value="dual">Dual (Controller + Worker)</option>
<option value="controller">Controller Only</option>
<option value="worker">Worker Only</option>
</select>
</div>
<button type="submit" class="btn btn-primary" [disabled]="!newNodeName || actionInProgress() === 'init-node'">
@if (actionInProgress() === 'init-node') {
<div class="spinner-sm"></div>
Initializing...
} @else {
Initialize Node
}
</button>
</form>
</div>
}
</section>
<!-- Peers Section -->
@if (nodeService.initialized()) {
<section class="peers-section">
<div class="section-header">
<h2 class="section-title">
<svg class="section-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
Connected Peers
</h2>
<button class="btn btn-secondary" (click)="showAddPeerModal = true" [disabled]="actionInProgress() !== null">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Add Peer
</button>
</div>
@if (nodeService.peers().length > 0) {
<div class="peers-table-container">
<table class="peers-table">
<thead>
<tr>
<th>Peer</th>
<th>Address</th>
<th class="text-center">Role</th>
<th class="text-right">Ping</th>
<th class="text-right">Score</th>
<th class="text-center">Last Seen</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
@for (peer of nodeService.peers(); track peer.id) {
<tr>
<td>
<div class="peer-name">
<div class="status-dot" [class.online]="peer.status === 'online'" [class.offline]="peer.status !== 'online'"></div>
<span>{{ peer.name }}</span>
</div>
</td>
<td>
<code class="address-code">{{ peer.address }}</code>
</td>
<td class="text-center">
<span class="role-badge" [class]="peer.role">{{ peer.role }}</span>
</td>
<td class="text-right tabular-nums">
@if (peer.pingMs > 0) {
<span [class.text-success-500]="peer.pingMs < 50"
[class.text-warning-500]="peer.pingMs >= 50 && peer.pingMs < 200"
[class.text-danger-500]="peer.pingMs >= 200">
{{ peer.pingMs.toFixed(0) }}ms
</span>
} @else {
<span class="text-muted">-</span>
}
</td>
<td class="text-right tabular-nums">
<span [class.text-success-500]="peer.score >= 90"
[class.text-warning-500]="peer.score >= 50 && peer.score < 90"
[class.text-danger-500]="peer.score < 50">
{{ peer.score.toFixed(0) }}
</span>
</td>
<td class="text-center">
<span class="text-muted">{{ formatLastSeen(peer.lastSeen) }}</span>
</td>
<td class="actions-cell">
<button class="icon-btn" title="Ping" (click)="pingPeer(peer.id)" [disabled]="actionInProgress() === 'ping-' + peer.id">
@if (actionInProgress() === 'ping-' + peer.id) {
<div class="spinner-sm"></div>
} @else {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.142 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
</svg>
}
</button>
<button class="icon-btn" title="View Stats" (click)="viewPeerStats(peer)" [disabled]="actionInProgress() !== null">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</button>
<button class="icon-btn icon-btn-danger" title="Remove" (click)="removePeer(peer.id)" [disabled]="actionInProgress() === 'remove-' + peer.id">
@if (actionInProgress() === 'remove-' + peer.id) {
<div class="spinner-sm"></div>
} @else {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="empty-state">
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" width="64" height="64">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<h3>No Peers Connected</h3>
<p>Add peers to manage remote mining rigs from this dashboard.</p>
<button class="btn btn-primary" (click)="showAddPeerModal = true" [disabled]="actionInProgress() !== null">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Add First Peer
</button>
</div>
}
</section>
}
<!-- Add Peer Modal -->
@if (showAddPeerModal) {
<div class="modal-overlay" (click)="showAddPeerModal = false">
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>Add Peer</h3>
<button class="close-btn" (click)="showAddPeerModal = false">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form class="modal-body" (ngSubmit)="addPeer()">
<div class="form-group">
<label for="peerAddress">Peer Address</label>
<input
id="peerAddress"
type="text"
[(ngModel)]="newPeerAddress"
name="peerAddress"
placeholder="e.g., 192.168.1.100:9091"
required>
<span class="hint">Enter the IP address and port of the remote node</span>
</div>
<div class="form-group">
<label for="peerName">Peer Name (optional)</label>
<input
id="peerName"
type="text"
[(ngModel)]="newPeerName"
name="peerName"
placeholder="e.g., rig-alpha">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" (click)="showAddPeerModal = false" [disabled]="actionInProgress() === 'add-peer'">Cancel</button>
<button type="submit" class="btn btn-primary" [disabled]="!newPeerAddress || actionInProgress() === 'add-peer'">
@if (actionInProgress() === 'add-peer') {
<div class="spinner-sm"></div>
Adding...
} @else {
Add Peer
}
</button>
</div>
</form>
</div>
</div>
}
<!-- Peer Stats Modal -->
@if (selectedPeer) {
<div class="modal-overlay" (click)="selectedPeer = null">
<div class="modal modal-wide" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>{{ selectedPeer.name }} - Stats</h3>
<button class="close-btn" (click)="selectedPeer = null">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="modal-body">
@if (selectedPeerStats) {
<div class="peer-stats-grid">
@for (miner of selectedPeerStats.miners; track miner.name) {
<div class="miner-stat-card">
<div class="miner-header">
<div class="status-dot online"></div>
<span class="miner-name">{{ miner.name }}</span>
<span class="algo-badge">{{ miner.algorithm }}</span>
</div>
<div class="miner-stats">
<div class="stat-item">
<span class="stat-value">{{ formatHashrate(miner.hashrate) }}</span>
<span class="stat-label">Hashrate</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ miner.shares }}</span>
<span class="stat-label">Shares</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ formatUptime(miner.uptime) }}</span>
<span class="stat-label">Uptime</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="loading-stats">
<div class="spinner"></div>
<span>Loading stats...</span>
</div>
}
</div>
</div>
</div>
}
</div>
`,
styles: [`
.nodes-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: white;
margin: 0 0 1rem 0;
}
.section-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.w-4 { width: 16px; }
.h-4 { height: 16px; }
.w-5 { width: 20px; }
.h-5 { height: 20px; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.section-header .section-title {
margin: 0;
}
/* Identity Card */
.identity-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--color-surface-100);
border-radius: 0.5rem;
border: 1px solid rgb(37 37 66 / 0.2);
}
.identity-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.identity-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: white;
}
.identity-id {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.identity-id .label {
color: #64748b;
}
.identity-id code {
padding: 0.25rem 0.5rem;
background: rgb(37 37 66 / 0.5);
border-radius: 0.25rem;
font-family: var(--font-family-mono);
font-size: 0.75rem;
color: var(--color-accent-400);
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 0.25rem;
color: #64748b;
cursor: pointer;
transition: all 0.15s ease;
}
.copy-btn:hover {
background: rgb(37 37 66 / 0.5);
color: white;
}
.identity-stats {
display: flex;
gap: 2rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: white;
}
.stat-label {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
}
/* Role Badge */
.role-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
text-transform: uppercase;
font-weight: 600;
}
.role-badge.dual {
background: rgb(0 212 255 / 0.1);
color: var(--color-accent-500);
}
.role-badge.controller {
background: rgb(168 85 247 / 0.1);
color: rgb(168 85 247);
}
.role-badge.worker {
background: rgb(34 197 94 / 0.1);
color: var(--color-success-500);
}
/* Init Card */
.init-card {
padding: 2rem;
background: var(--color-surface-100);
border-radius: 0.5rem;
border: 1px solid rgb(37 37 66 / 0.2);
text-align: center;
}
.init-card h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
color: white;
}
.init-card p {
margin: 0 0 1.5rem 0;
color: #64748b;
font-size: 0.875rem;
}
.init-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 320px;
margin: 0 auto;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
text-align: left;
}
.form-group label {
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
}
.form-group input,
.form-group select {
padding: 0.5rem 0.75rem;
background: var(--color-surface-200);
border: 1px solid rgb(37 37 66 / 0.3);
border-radius: 0.375rem;
color: white;
font-size: 0.875rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--color-accent-500);
}
.form-group .hint {
font-size: 0.75rem;
color: #64748b;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-accent-500);
color: #0f0f1a;
}
.btn-primary:hover:not(:disabled) {
background: rgb(0 212 255 / 0.8);
}
.btn-secondary {
background: rgb(37 37 66 / 0.5);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: rgb(37 37 66 / 0.8);
}
/* Peers Table */
.peers-table-container {
background: var(--color-surface-100);
border-radius: 0.5rem;
border: 1px solid rgb(37 37 66 / 0.2);
overflow: hidden;
}
.peers-table {
width: 100%;
border-collapse: collapse;
}
.peers-table th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--color-surface-200);
border-bottom: 1px solid rgb(37 37 66 / 0.2);
}
.peers-table td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #e2e8f0;
border-bottom: 1px solid rgb(37 37 66 / 0.1);
}
.peers-table tbody tr:hover {
background: rgb(37 37 66 / 0.2);
}
.text-right { text-align: right; }
.text-center { text-align: center; }
.tabular-nums { font-variant-numeric: tabular-nums; }
.text-muted { color: #64748b; }
.text-success-500 { color: var(--color-success-500); }
.text-warning-500 { color: var(--color-warning-500); }
.text-danger-500 { color: var(--color-danger-500); }
.peer-name {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #64748b;
}
.status-dot.online {
background: var(--color-success-500);
box-shadow: 0 0 6px var(--color-success-500);
}
.status-dot.offline {
background: #64748b;
}
.address-code {
padding: 0.125rem 0.375rem;
background: rgb(37 37 66 / 0.5);
border-radius: 0.25rem;
font-family: var(--font-family-mono);
font-size: 0.75rem;
color: #94a3b8;
}
.actions-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 0.25rem;
color: #94a3b8;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-btn:hover {
background: rgb(37 37 66 / 0.5);
color: white;
}
.icon-btn-danger:hover {
background: rgb(239 68 68 / 0.2);
color: var(--color-danger-500);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
background: var(--color-surface-100);
border-radius: 0.5rem;
border: 1px solid rgb(37 37 66 / 0.2);
text-align: center;
}
.empty-icon {
color: #475569;
}
.empty-state h3 {
margin: 1rem 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: white;
}
.empty-state p {
margin: 0 0 1.5rem;
color: #64748b;
font-size: 0.875rem;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
}
.modal {
width: 100%;
max-width: 400px;
background: var(--color-surface-100);
border-radius: 0.5rem;
border: 1px solid rgb(37 37 66 / 0.3);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.modal-wide {
max-width: 600px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid rgb(37 37 66 / 0.2);
}
.modal-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: white;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 0.25rem;
color: #64748b;
cursor: pointer;
transition: all 0.15s ease;
}
.close-btn:hover {
background: rgb(37 37 66 / 0.5);
color: white;
}
.modal-body {
padding: 1.5rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
/* Peer Stats */
.peer-stats-grid {
display: grid;
gap: 1rem;
}
.miner-stat-card {
padding: 1rem;
background: var(--color-surface-200);
border-radius: 0.5rem;
}
.miner-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.miner-name {
font-weight: 600;
color: white;
}
.algo-badge {
padding: 0.125rem 0.375rem;
background: rgb(0 212 255 / 0.1);
border-radius: 0.25rem;
font-size: 0.6875rem;
color: var(--color-accent-500);
text-transform: uppercase;
}
.miner-stats {
display: flex;
gap: 1.5rem;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-item .stat-value {
font-size: 1rem;
font-weight: 600;
color: white;
}
.stat-item .stat-label {
font-size: 0.6875rem;
color: #64748b;
text-transform: uppercase;
}
.loading-stats {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: #64748b;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgb(37 37 66 / 0.5);
border-top-color: var(--color-accent-500);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid rgb(255 255 255 / 0.3);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`]
})
export class NodesComponent implements OnInit, OnDestroy {
nodeService = inject(NodeService);
actionInProgress = signal<string | null>(null);
// Init form
newNodeName = '';
newNodeRole: 'controller' | 'worker' | 'dual' = 'dual';
// Add peer modal
showAddPeerModal = false;
newPeerAddress = '';
newPeerName = '';
// Peer stats modal
selectedPeer: Peer | null = null;
selectedPeerStats: { miners: any[] } | null = null;
ngOnInit() {
this.nodeService.startPolling();
}
ngOnDestroy() {
this.nodeService.stopPolling();
}
initializeNode() {
if (!this.newNodeName) return;
this.actionInProgress.set('init-node');
this.nodeService.initNode(this.newNodeName, this.newNodeRole).subscribe({
next: () => {
this.actionInProgress.set(null);
this.newNodeName = '';
this.newNodeRole = 'dual';
},
error: (err) => {
this.actionInProgress.set(null);
console.error('Failed to initialize node:', err);
}
});
}
addPeer() {
if (!this.newPeerAddress) return;
this.actionInProgress.set('add-peer');
this.nodeService.addPeer(this.newPeerAddress, this.newPeerName || undefined).subscribe({
next: () => {
this.actionInProgress.set(null);
this.showAddPeerModal = false;
this.newPeerAddress = '';
this.newPeerName = '';
},
error: (err) => {
this.actionInProgress.set(null);
console.error('Failed to add peer:', err);
}
});
}
removePeer(peerId: string) {
this.actionInProgress.set(`remove-${peerId}`);
this.nodeService.removePeer(peerId).subscribe({
next: () => this.actionInProgress.set(null),
error: (err) => {
this.actionInProgress.set(null);
console.error('Failed to remove peer:', err);
}
});
}
pingPeer(peerId: string) {
this.actionInProgress.set(`ping-${peerId}`);
this.nodeService.pingPeer(peerId).subscribe({
next: () => this.actionInProgress.set(null),
error: (err) => {
this.actionInProgress.set(null);
console.error('Failed to ping peer:', err);
}
});
}
viewPeerStats(peer: Peer) {
this.selectedPeer = peer;
this.selectedPeerStats = null;
this.nodeService.getPeerStats(peer.id).subscribe({
next: (stats) => {
this.selectedPeerStats = stats;
},
error: (err) => {
console.error('Failed to get peer stats:', err);
this.selectedPeerStats = { miners: [] };
}
});
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
formatLastSeen(lastSeen: string): string {
if (!lastSeen) return 'Never';
const date = new Date(lastSeen);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
if (diffSecs < 60) return `${diffSecs}s ago`;
if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`;
if (diffSecs < 86400) return `${Math.floor(diffSecs / 3600)}h ago`;
return `${Math.floor(diffSecs / 86400)}d ago`;
}
formatHashrate(hashrate: number): string {
if (hashrate >= 1000000000) return `${(hashrate / 1000000000).toFixed(2)} GH/s`;
if (hashrate >= 1000000) return `${(hashrate / 1000000).toFixed(2)} MH/s`;
if (hashrate >= 1000) return `${(hashrate / 1000).toFixed(2)} kH/s`;
return `${hashrate.toFixed(0)} H/s`;
}
formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins}m`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}
}

View file

@ -1,109 +1,99 @@
<!--
This file has been updated to correctly bind to Web Awesome/Shoelace custom element events
and includes styling to improve the form's layout.
-->
<style>
.profile-form {
display: flex;
flex-direction: column;
gap: 1.5rem; /* Use a gap for consistent spacing */
}
fieldset {
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid var(--wa-color-neutral-300);
border-radius: var(--wa-border-radius-medium);
padding: 1rem;
}
legend {
padding: 0 0.5rem;
font-weight: bold;
color: var(--wa-color-neutral-700);
}
/* Style for checkboxes to align them nicely */
.checkbox-group {
display: flex;
gap: 2rem;
align-items: center;
}
</style>
<form class="profile-form" id="profile-create-form" (submit)="createProfile(); $event.preventDefault()">
<h5>Create New Profile</h5>
<form class="flex flex-col gap-4" id="profile-create-form" (submit)="createProfile(); $event.preventDefault()">
<h5 class="text-lg font-semibold">Create New Profile</h5>
@if (error) {
<wa-card class="card-error">
<div class="card bg-danger-50 border-danger-500 p-3 text-danger-600 text-sm">
<p>{{ error }}</p>
</wa-card>
</div>
}
@if (success) {
<wa-card class="card-success">
<div class="card bg-success-50 border-success-500 p-3 text-success-600 text-sm">
<p>{{ success }}</p>
</wa-card>
</div>
}
<!--
We use standard (input) and (change) events.
These events bubble up from the custom elements (or are dispatched by them),
and event.target will refer to the custom element itself, allowing us to read 'value'.
-->
<wa-input
name="name"
label="Profile Name"
required
[value]="model.name"
(input)="onNameInput($event)">
</wa-input>
<wa-select
name="minerType"
label="Miner Type"
required
[value]="model.minerType"
(change)="onMinerTypeChange($event)">
@for(miner of state().manageableMiners; track miner.name) {
<wa-option [value]="miner.name">{{ miner.name }}</wa-option>
}
</wa-select>
<fieldset name="config">
<legend>Configuration</legend>
<wa-input
name="pool"
label="Pool Address"
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Profile Name</label>
<input
type="text"
class="input"
name="name"
required
[value]="model.config.pool"
(input)="onPoolInput($event)">
</wa-input>
[value]="model.name"
(input)="onNameInput($event)">
</div>
<wa-input
name="wallet"
label="Wallet Address"
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Miner Type</label>
<select
class="select"
name="minerType"
required
[value]="model.config.wallet"
(input)="onWalletInput($event)">
</wa-input>
[value]="model.minerType"
(change)="onMinerTypeChange($event)">
<option value="" disabled selected>Select Miner</option>
@for(miner of state().manageableMiners; track miner.name) {
<option [value]="miner.name">{{ miner.name }}</option>
}
</select>
</div>
<div class="checkbox-group">
<wa-checkbox
name="tls"
[checked]="model.config.tls"
(change)="onTlsChange($event)">
TLS
</wa-checkbox>
<fieldset class="card p-4">
<legend class="px-2 font-semibold text-slate-300">Configuration</legend>
<wa-checkbox
name="hugePages"
[checked]="model.config.hugePages"
(change)="onHugePagesChange($event)">
Huge Pages
</wa-checkbox>
<div class="flex flex-col gap-4 mt-3">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Pool Address</label>
<input
type="text"
class="input"
name="pool"
required
[value]="model.config.pool"
(input)="onPoolInput($event)">
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Wallet Address</label>
<input
type="text"
class="input"
name="wallet"
required
[value]="model.config.wallet"
(input)="onWalletInput($event)">
</div>
<div class="flex gap-6 items-center">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="w-4 h-4 rounded border-surface-50 text-accent-500 focus:ring-accent-500"
name="tls"
[checked]="model.config.tls"
(change)="onTlsChange($event)">
<span class="text-sm">TLS</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="w-4 h-4 rounded border-surface-50 text-accent-500 focus:ring-accent-500"
name="hugePages"
[checked]="model.config.hugePages"
(change)="onHugePagesChange($event)">
<span class="text-sm">Huge Pages</span>
</label>
</div>
</div>
</fieldset>
<wa-button type="submit">Create Profile</wa-button>
<button type="submit" class="btn btn-primary" [disabled]="isCreating()">
@if (isCreating()) {
<div class="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-1"></div>
Creating...
} @else {
Create Profile
}
</button>
</form>

View file

@ -1,16 +1,9 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject } from '@angular/core';
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { MinerService, MiningProfile } from './miner.service';
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
import '@awesome.me/webawesome/dist/components/input/input.js';
import '@awesome.me/webawesome/dist/components/select/select.js';
import '@awesome.me/webawesome/dist/components/checkbox/checkbox.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/card/card.js';
@Component({
selector: 'snider-mining-profile-create',
@ -39,6 +32,7 @@ export class ProfileCreateComponent {
// Simple properties instead of signals
error: string | null = null;
success: string | null = null;
isCreating = signal(false);
// --- Event Handlers for Custom Elements ---
// By handling events here, we can safely cast the event target
@ -78,8 +72,10 @@ export class ProfileCreateComponent {
return;
}
this.isCreating.set(true);
this.minerService.createProfile(this.model).subscribe({
next: () => {
this.isCreating.set(false);
this.success = 'Profile created successfully!';
// Reset form to defaults
this.model = {
@ -96,6 +92,7 @@ export class ProfileCreateComponent {
setTimeout(() => this.success = null, 3000);
},
error: (err: HttpErrorResponse) => {
this.isCreating.set(false);
console.error(err);
if (err.error && err.error.error) {
this.error = `Failed to create profile: ${err.error.error}`;

View file

@ -1,61 +1,117 @@
<div class="profile-list">
<h5>Existing Profiles</h5>
<div class="flex flex-col gap-4">
<h5 class="text-lg font-semibold">Existing Profiles</h5>
@for(profile of state().profiles; track profile.id) {
<div class="profile-item">
<div class="card p-4">
@if(editingProfile && editingProfile.id === profile.id) {
<div class="profile-form">
<wa-input
label="Profile Name"
[value]="editingProfile.name"
(input)="onNameInput($event)">
</wa-input>
<wa-select
label="Miner Type"
[value]="editingProfile.minerType"
(change)="onMinerTypeChange($event)">
@for(miner of state().manageableMiners; track miner.name) {
<wa-option [value]="miner.name" [selected]="miner.name === editingProfile.minerType">
{{ miner.name }}
</wa-option>
}
</wa-select>
<wa-input
label="Pool Address"
[value]="editingProfile.config.pool"
(input)="onPoolInput($event)">
</wa-input>
<wa-input
label="Wallet Address"
[value]="editingProfile.config.wallet"
(input)="onWalletInput($event)">
</wa-input>
<div class="checkbox-group">
<wa-checkbox
[checked]="editingProfile.config.tls"
(change)="onTlsChange($event)">
TLS
</wa-checkbox>
<wa-checkbox
[checked]="editingProfile.config.hugePages"
(change)="onHugePagesChange($event)">
Huge Pages
</wa-checkbox>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Profile Name</label>
<input
type="text"
class="input"
[value]="editingProfile.name"
(input)="onNameInput($event)">
</div>
<div class="button-group">
<wa-button (click)="updateProfile()">Save</wa-button>
<wa-button variant="neutral" (click)="cancelEdit()">Cancel</wa-button>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Miner Type</label>
<select
class="select"
[value]="editingProfile.minerType"
(change)="onMinerTypeChange($event)">
@for(miner of state().manageableMiners; track miner.name) {
<option [value]="miner.name" [selected]="miner.name === editingProfile.minerType">
{{ miner.name }}
</option>
}
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Pool Address</label>
<input
type="text"
class="input"
[value]="editingProfile.config.pool"
(input)="onPoolInput($event)">
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-slate-300">Wallet Address</label>
<input
type="text"
class="input"
[value]="editingProfile.config.wallet"
(input)="onWalletInput($event)">
</div>
<div class="flex gap-6 items-center">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="w-4 h-4 rounded border-surface-50 text-accent-500 focus:ring-accent-500"
[checked]="editingProfile.config.tls"
(change)="onTlsChange($event)">
<span class="text-sm">TLS</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
class="w-4 h-4 rounded border-surface-50 text-accent-500 focus:ring-accent-500"
[checked]="editingProfile.config.hugePages"
(change)="onHugePagesChange($event)">
<span class="text-sm">Huge Pages</span>
</label>
</div>
<div class="flex gap-2">
<button
class="btn btn-primary btn-sm"
[disabled]="actionInProgress() === 'save-' + editingProfile.id"
(click)="updateProfile()">
@if (actionInProgress() === 'save-' + editingProfile.id) {
<div class="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-1"></div>
Saving...
} @else {
Save
}
</button>
<button
class="btn btn-secondary btn-sm"
[disabled]="actionInProgress() === 'save-' + editingProfile.id"
(click)="cancelEdit()">Cancel</button>
</div>
</div>
} @else {
<span>{{ profile.name }} ({{ profile.minerType }})</span>
<div class="button-group">
<wa-button size="small" variant="primary" (click)="startMiner(profile.id)">Start</wa-button>
<wa-button size="small" (click)="editProfile(profile)">Edit</wa-button>
<wa-button size="small" variant="danger" (click)="deleteProfile(profile.id)">Delete</wa-button>
<div class="flex justify-between items-center">
<span class="font-medium">{{ profile.name }} <span class="text-slate-400">({{ profile.minerType }})</span></span>
<div class="flex gap-2">
<button
class="btn btn-primary btn-xs"
[disabled]="actionInProgress() === 'start-' + profile.id"
(click)="startMiner(profile.id)">
@if (actionInProgress() === 'start-' + profile.id) {
<div class="animate-spin w-3 h-3 border-2 border-white border-t-transparent rounded-full mr-1"></div>
Starting...
} @else {
Start
}
</button>
<button
class="btn btn-secondary btn-xs"
[disabled]="actionInProgress() !== null"
(click)="editProfile(profile)">Edit</button>
<button
class="btn btn-danger btn-xs"
[disabled]="actionInProgress() === 'delete-' + profile.id"
(click)="deleteProfile(profile.id)">
@if (actionInProgress() === 'delete-' + profile.id) {
<div class="animate-spin w-3 h-3 border-2 border-white border-t-transparent rounded-full mr-1"></div>
Deleting...
} @else {
Delete
}
</button>
</div>
</div>
}
</div>
} @empty {
<p>No profiles created yet.</p>
<p class="text-slate-400 italic">No profiles created yet.</p>
}
</div>

View file

@ -16,6 +16,7 @@ export class ProfileListComponent {
state = this.minerService.state;
editingProfile: (MiningProfile & { config: any }) | null = null;
actionInProgress = signal<string | null>(null);
// --- Event Handlers for Custom Elements in Edit Form ---
onNameInput(event: Event) {
@ -55,11 +56,19 @@ export class ProfileListComponent {
}
startMiner(profileId: string) {
this.minerService.startMiner(profileId).subscribe();
this.actionInProgress.set(`start-${profileId}`);
this.minerService.startMiner(profileId).subscribe({
next: () => this.actionInProgress.set(null),
error: () => this.actionInProgress.set(null)
});
}
deleteProfile(profileId: string) {
this.minerService.deleteProfile(profileId).subscribe();
this.actionInProgress.set(`delete-${profileId}`);
this.minerService.deleteProfile(profileId).subscribe({
next: () => this.actionInProgress.set(null),
error: () => this.actionInProgress.set(null)
});
}
editProfile(profile: MiningProfile) {
@ -69,8 +78,13 @@ export class ProfileListComponent {
updateProfile() {
if (!this.editingProfile) return;
this.minerService.updateProfile(this.editingProfile).subscribe(() => {
this.editingProfile = null;
this.actionInProgress.set(`save-${this.editingProfile.id}`);
this.minerService.updateProfile(this.editingProfile).subscribe({
next: () => {
this.actionInProgress.set(null);
this.editingProfile = null;
},
error: () => this.actionInProgress.set(null)
});
}