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()) { +
+
+

+ + + + Connected Peers +

+ +
+ + @if (nodeService.peers().length > 0) { +
+ + + + + + + + + + + + + + @for (peer of nodeService.peers(); track peer.id) { + + + + + + + + + + } + +
PeerAddressRolePingScoreLast SeenActions
+
+
+ {{ peer.name }} +
+
+ {{ peer.address }} + + {{ peer.role }} + + @if (peer.pingMs > 0) { + + {{ peer.pingMs.toFixed(0) }}ms + + } @else { + - + } + + + {{ 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) { + + } +
+ `, + 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(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`; + } +} diff --git a/ui/src/app/profile-create.component.html b/ui/src/app/profile-create.component.html index 25ab67f..372e40a 100644 --- a/ui/src/app/profile-create.component.html +++ b/ui/src/app/profile-create.component.html @@ -1,109 +1,99 @@ - - - -
-
Create New Profile
+ +
Create New Profile
@if (error) { - +

{{ error }}

- +
} @if (success) { - +

{{ success }}

- +
} - - - - - - @for(miner of state().manageableMiners; track miner.name) { - {{ miner.name }} - } - - -
- Configuration - + + - + [value]="model.name" + (input)="onNameInput($event)"> + - + + + -
- - TLS - +
+ Configuration - - Huge Pages - +
+
+ + +
+ +
+ + +
+ +
+ + + +
- Create Profile + diff --git a/ui/src/app/profile-create.component.ts b/ui/src/app/profile-create.component.ts index d8fe7bc..755b49a 100644 --- a/ui/src/app/profile-create.component.ts +++ b/ui/src/app/profile-create.component.ts @@ -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}`; diff --git a/ui/src/app/profile-list.component.html b/ui/src/app/profile-list.component.html index aab2f74..928db72 100644 --- a/ui/src/app/profile-list.component.html +++ b/ui/src/app/profile-list.component.html @@ -1,61 +1,117 @@ -
-
Existing Profiles
+
+
Existing Profiles
@for(profile of state().profiles; track profile.id) { -
+
@if(editingProfile && editingProfile.id === profile.id) { -
- - - - @for(miner of state().manageableMiners; track miner.name) { - - {{ miner.name }} - - } - - - - - -
- - TLS - - - Huge Pages - +
+
+ +
-
- Save - Cancel +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
} @else { - {{ profile.name }} ({{ profile.minerType }}) -
- Start - Edit - Delete +
+ {{ profile.name }} ({{ profile.minerType }}) +
+ + + +
}
} @empty { -

No profiles created yet.

+

No profiles created yet.

}
diff --git a/ui/src/app/profile-list.component.ts b/ui/src/app/profile-list.component.ts index 0274973..5c97eb3 100644 --- a/ui/src/app/profile-list.component.ts +++ b/ui/src/app/profile-list.component.ts @@ -16,6 +16,7 @@ export class ProfileListComponent { state = this.minerService.state; editingProfile: (MiningProfile & { config: any }) | null = null; + actionInProgress = signal(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) }); }