diff --git a/ui/src/app/pages/nodes/nodes.component.ts b/ui/src/app/pages/nodes/nodes.component.ts
new file mode 100644
index 0000000..ddbc93e
--- /dev/null
+++ b/ui/src/app/pages/nodes/nodes.component.ts
@@ -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: `
+
+
+
+
+
+ Local Node
+
+
+ @if (nodeService.initialized()) {
+
+
+
+
+
{{ nodeService.identity()?.name }}
+
+ {{ nodeService.identity()?.role }}
+
+
+
+
Node ID:
+
{{ nodeService.identity()?.id }}
+
+
+
+
+
+ {{ nodeService.peers().length }}
+ Peers
+
+
+ {{ nodeService.onlinePeers().length }}
+ Online
+
+
+
+ } @else {
+
+
Initialize Node Identity
+
Set up your node to enable P2P communication with remote mining rigs.
+
+
+ }
+
+
+
+ @if (nodeService.initialized()) {
+
+
+
+ @if (nodeService.peers().length > 0) {
+
+
+
+
+ | Peer |
+ Address |
+ Role |
+ Ping |
+ Score |
+ Last Seen |
+ Actions |
+
+
+
+ @for (peer of nodeService.peers(); track peer.id) {
+
+ |
+
+ |
+
+ {{ peer.address }}
+ |
+
+ {{ peer.role }}
+ |
+
+ @if (peer.pingMs > 0) {
+ = 50 && peer.pingMs < 200"
+ [class.text-danger-500]="peer.pingMs >= 200">
+ {{ peer.pingMs.toFixed(0) }}ms
+
+ } @else {
+ -
+ }
+ |
+
+ = 90"
+ [class.text-warning-500]="peer.score >= 50 && peer.score < 90"
+ [class.text-danger-500]="peer.score < 50">
+ {{ peer.score.toFixed(0) }}
+
+ |
+
+ {{ formatLastSeen(peer.lastSeen) }}
+ |
+
+
+
+
+ |
+
+ }
+
+
+
+ } @else {
+
+
+
No Peers Connected
+
Add peers to manage remote mining rigs from this dashboard.
+
+
+ }
+
+ }
+
+
+ @if (showAddPeerModal) {
+
+ }
+
+
+ @if (selectedPeer) {
+
+
+
+
+ @if (selectedPeerStats) {
+
+ @for (miner of selectedPeerStats.miners; track miner.name) {
+
+
+
+
+ {{ formatHashrate(miner.hashrate) }}
+ Hashrate
+
+
+ {{ miner.shares }}
+ Shares
+
+
+ {{ formatUptime(miner.uptime) }}
+ Uptime
+
+
+
+ }
+
+ } @else {
+
+ }
+
+
+
+ }
+
+ `,
+ 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