feat(ui): Add notifications, loading states, mobile support, and inline editing
- Add notification service with toast component for success/error/warning/info messages - Add API status banner showing when backend is unavailable with retry button - Add loading spinners to all async action buttons (start/stop/install/delete) - Add mobile responsive drawer sidebar with hamburger menu - Add responsive styles for workers table, profiles grid, and miners grid - Add inline profile editing with save/cancel functionality - Add unit tests for notification service, toast component, and sidebar (36 tests passing) - Fix broken app.spec.ts test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d51ba83efa
commit
e3122bb41e
12 changed files with 1407 additions and 80 deletions
|
|
@ -1,23 +1,18 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { SniderMining } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
describe('SniderMining', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
imports: [SniderMining],
|
||||
providers: [provideHttpClient()]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const fixture = TestBed.createComponent(SniderMining);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, ui');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
106
ui/src/app/components/api-status/api-status.component.ts
Normal file
106
ui/src/app/components/api-status/api-status.component.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MinerService } from '../../miner.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-api-status',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (!minerService.apiAvailable()) {
|
||||
<div class="api-error-banner">
|
||||
<div class="banner-content">
|
||||
<svg class="banner-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="banner-text">
|
||||
<span class="banner-title">Connection Error</span>
|
||||
<span class="banner-message">Unable to connect to the mining API. Make sure the backend is running.</span>
|
||||
</div>
|
||||
<button class="retry-btn" (click)="retry()">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.api-error-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9998;
|
||||
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
|
||||
border-bottom: 1px solid rgb(239 68 68 / 0.3);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: #fca5a5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #fef2f2;
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
font-size: 0.8125rem;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
color: white;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.retry-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ApiStatusComponent {
|
||||
minerService = inject(MinerService);
|
||||
|
||||
retry() {
|
||||
this.minerService.forceRefreshState();
|
||||
}
|
||||
}
|
||||
122
ui/src/app/components/sidebar/sidebar.component.spec.ts
Normal file
122
ui/src/app/components/sidebar/sidebar.component.spec.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { SidebarComponent } from './sidebar.component';
|
||||
|
||||
describe('SidebarComponent', () => {
|
||||
let component: SidebarComponent;
|
||||
let fixture: ComponentFixture<SidebarComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SidebarComponent]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SidebarComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should start in expanded state', () => {
|
||||
expect(component.collapsed()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should start with mobile menu closed', () => {
|
||||
expect(component.mobileOpen()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should toggle collapsed state', () => {
|
||||
expect(component.collapsed()).toBeFalse();
|
||||
|
||||
component.toggleCollapse();
|
||||
expect(component.collapsed()).toBeTrue();
|
||||
|
||||
component.toggleCollapse();
|
||||
expect(component.collapsed()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should toggle mobile menu state', () => {
|
||||
expect(component.mobileOpen()).toBeFalse();
|
||||
|
||||
component.toggleMobileMenu();
|
||||
expect(component.mobileOpen()).toBeTrue();
|
||||
|
||||
component.toggleMobileMenu();
|
||||
expect(component.mobileOpen()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should close mobile menu', () => {
|
||||
component.mobileOpen.set(true);
|
||||
expect(component.mobileOpen()).toBeTrue();
|
||||
|
||||
component.closeMobileMenu();
|
||||
expect(component.mobileOpen()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should emit route change on navigate', () => {
|
||||
const emitSpy = spyOn(component.routeChange, 'emit');
|
||||
|
||||
component.navigate('workers');
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('workers');
|
||||
});
|
||||
|
||||
it('should emit route change and close mobile menu on navigateAndClose', () => {
|
||||
const emitSpy = spyOn(component.routeChange, 'emit');
|
||||
component.mobileOpen.set(true);
|
||||
|
||||
component.navigateAndClose('profiles');
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('profiles');
|
||||
expect(component.mobileOpen()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should have correct number of nav items', () => {
|
||||
expect(component.navItems.length).toBe(7);
|
||||
});
|
||||
|
||||
it('should have required routes', () => {
|
||||
const routes = component.navItems.map(item => item.route);
|
||||
|
||||
expect(routes).toContain('dashboard');
|
||||
expect(routes).toContain('workers');
|
||||
expect(routes).toContain('console');
|
||||
expect(routes).toContain('pools');
|
||||
expect(routes).toContain('profiles');
|
||||
expect(routes).toContain('miners');
|
||||
expect(routes).toContain('nodes');
|
||||
});
|
||||
|
||||
it('should render navigation items', () => {
|
||||
const navItems = fixture.nativeElement.querySelectorAll('.nav-item');
|
||||
expect(navItems.length).toBe(7);
|
||||
});
|
||||
|
||||
it('should apply active class to current route', () => {
|
||||
fixture.componentRef.setInput('currentRoute', 'workers');
|
||||
fixture.detectChanges();
|
||||
|
||||
const activeItem = fixture.nativeElement.querySelector('.nav-item.active');
|
||||
expect(activeItem).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show logo text when expanded', () => {
|
||||
component.collapsed.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const logoText = fixture.nativeElement.querySelector('.logo-text');
|
||||
expect(logoText).toBeTruthy();
|
||||
expect(logoText.textContent).toContain('Mining');
|
||||
});
|
||||
|
||||
it('should hide nav labels when collapsed on desktop', () => {
|
||||
component.collapsed.set(true);
|
||||
component.mobileOpen.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const navLabels = fixture.nativeElement.querySelectorAll('.nav-label');
|
||||
expect(navLabels.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, signal, output, input, inject } from '@angular/core';
|
||||
import { Component, signal, output, input, inject, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
|
||||
|
|
@ -14,18 +14,30 @@ interface NavItem {
|
|||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<aside class="sidebar" [class.collapsed]="collapsed()">
|
||||
<!-- Mobile menu button (visible on small screens) -->
|
||||
<button class="mobile-menu-btn" (click)="toggleMobileMenu()">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile backdrop -->
|
||||
@if (mobileOpen()) {
|
||||
<div class="mobile-backdrop" (click)="closeMobileMenu()"></div>
|
||||
}
|
||||
|
||||
<aside class="sidebar" [class.collapsed]="collapsed()" [class.mobile-open]="mobileOpen()">
|
||||
<!-- Logo / Brand -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<svg class="w-8 h-8 text-accent-500" 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>
|
||||
@if (!collapsed()) {
|
||||
@if (!collapsed() || mobileOpen()) {
|
||||
<span class="logo-text">Mining</span>
|
||||
}
|
||||
</div>
|
||||
<button class="collapse-btn" (click)="toggleCollapse()">
|
||||
<button class="collapse-btn desktop-only" (click)="toggleCollapse()">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@if (collapsed()) {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/>
|
||||
|
|
@ -34,6 +46,11 @@ interface NavItem {
|
|||
}
|
||||
</svg>
|
||||
</button>
|
||||
<button class="collapse-btn mobile-only" (click)="closeMobileMenu()">
|
||||
<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>
|
||||
|
||||
<!-- Navigation -->
|
||||
|
|
@ -42,10 +59,10 @@ interface NavItem {
|
|||
<button
|
||||
class="nav-item"
|
||||
[class.active]="currentRoute() === item.route"
|
||||
(click)="navigate(item.route)"
|
||||
[title]="collapsed() ? item.label : ''">
|
||||
(click)="navigateAndClose(item.route)"
|
||||
[title]="collapsed() && !mobileOpen() ? item.label : ''">
|
||||
<span class="nav-icon" [innerHTML]="item.icon"></span>
|
||||
@if (!collapsed()) {
|
||||
@if (!collapsed() || mobileOpen()) {
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
}
|
||||
</button>
|
||||
|
|
@ -54,7 +71,7 @@ interface NavItem {
|
|||
|
||||
<!-- Footer with miner switcher placeholder -->
|
||||
<div class="sidebar-footer">
|
||||
@if (!collapsed()) {
|
||||
@if (!collapsed() || mobileOpen()) {
|
||||
<div class="miner-status">
|
||||
<div class="status-indicator online"></div>
|
||||
<span class="status-text">Mining Active</span>
|
||||
|
|
@ -66,6 +83,33 @@ interface NavItem {
|
|||
</aside>
|
||||
`,
|
||||
styles: [`
|
||||
/* Mobile menu button */
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 1001;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface-200);
|
||||
border: 1px solid rgb(37 37 66 / 0.3);
|
||||
border-radius: 0.375rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -73,7 +117,7 @@ interface NavItem {
|
|||
height: 100vh;
|
||||
background: var(--color-surface-200);
|
||||
border-right: 1px solid rgb(37 37 66 / 0.2);
|
||||
transition: width 0.2s ease;
|
||||
transition: width 0.2s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
|
|
@ -208,15 +252,65 @@ interface NavItem {
|
|||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
width: 280px;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.collapsed .nav-item {
|
||||
justify-content: flex-start;
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SidebarComponent {
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
collapsed = signal(false);
|
||||
mobileOpen = signal(false);
|
||||
currentRoute = input<string>('dashboard');
|
||||
routeChange = output<string>();
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize() {
|
||||
// Close mobile menu on resize to larger screens
|
||||
if (window.innerWidth > 768 && this.mobileOpen()) {
|
||||
this.mobileOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
navItems: NavItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
|
|
@ -270,7 +364,20 @@ export class SidebarComponent {
|
|||
this.collapsed.update(v => !v);
|
||||
}
|
||||
|
||||
toggleMobileMenu() {
|
||||
this.mobileOpen.update(v => !v);
|
||||
}
|
||||
|
||||
closeMobileMenu() {
|
||||
this.mobileOpen.set(false);
|
||||
}
|
||||
|
||||
navigate(route: string) {
|
||||
this.routeChange.emit(route);
|
||||
}
|
||||
|
||||
navigateAndClose(route: string) {
|
||||
this.routeChange.emit(route);
|
||||
this.closeMobileMenu();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
ui/src/app/components/toast/toast.component.spec.ts
Normal file
107
ui/src/app/components/toast/toast.component.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ToastComponent } from './toast.component';
|
||||
import { NotificationService } from '../../notification.service';
|
||||
|
||||
describe('ToastComponent', () => {
|
||||
let component: ToastComponent;
|
||||
let fixture: ComponentFixture<ToastComponent>;
|
||||
let notificationService: NotificationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ToastComponent],
|
||||
providers: [NotificationService]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ToastComponent);
|
||||
component = fixture.componentInstance;
|
||||
notificationService = TestBed.inject(NotificationService);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display no toasts when there are no notifications', () => {
|
||||
const toasts = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toasts.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should display a success toast', () => {
|
||||
notificationService.success('Success message', 'Success');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toasts = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toasts.length).toBe(1);
|
||||
|
||||
const toast = toasts[0];
|
||||
expect(toast.classList.contains('toast-success')).toBeTrue();
|
||||
expect(toast.textContent).toContain('Success message');
|
||||
expect(toast.textContent).toContain('Success');
|
||||
});
|
||||
|
||||
it('should display an error toast', () => {
|
||||
notificationService.error('Error message', 'Error');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toast = fixture.nativeElement.querySelector('.toast-error');
|
||||
expect(toast).toBeTruthy();
|
||||
expect(toast.textContent).toContain('Error message');
|
||||
});
|
||||
|
||||
it('should display a warning toast', () => {
|
||||
notificationService.warning('Warning message', 'Warning');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toast = fixture.nativeElement.querySelector('.toast-warning');
|
||||
expect(toast).toBeTruthy();
|
||||
expect(toast.textContent).toContain('Warning message');
|
||||
});
|
||||
|
||||
it('should display an info toast', () => {
|
||||
notificationService.info('Info message', 'Info');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toast = fixture.nativeElement.querySelector('.toast-info');
|
||||
expect(toast).toBeTruthy();
|
||||
expect(toast.textContent).toContain('Info message');
|
||||
});
|
||||
|
||||
it('should display multiple toasts', () => {
|
||||
notificationService.success('Message 1');
|
||||
notificationService.error('Message 2');
|
||||
notificationService.warning('Message 3');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toasts = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toasts.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should dismiss a toast when close button is clicked', () => {
|
||||
notificationService.success('Test message');
|
||||
fixture.detectChanges();
|
||||
|
||||
let toasts = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toasts.length).toBe(1);
|
||||
|
||||
const closeButton = fixture.nativeElement.querySelector('.toast-close');
|
||||
closeButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
toasts = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toasts.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should have correct icon for each notification type', () => {
|
||||
notificationService.success('Success');
|
||||
notificationService.error('Error');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toasts = fixture.nativeElement.querySelectorAll('.toast');
|
||||
toasts.forEach((toast: Element) => {
|
||||
const icon = toast.querySelector('.toast-icon svg');
|
||||
expect(icon).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
ui/src/app/components/toast/toast.component.ts
Normal file
190
ui/src/app/components/toast/toast.component.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NotificationService, Notification, NotificationType } from '../../notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="toast-container">
|
||||
@for (notification of notificationService.notifications(); track notification.id) {
|
||||
<div
|
||||
class="toast"
|
||||
[class]="'toast-' + notification.type"
|
||||
(click)="dismiss(notification.id)"
|
||||
>
|
||||
<div class="toast-icon">
|
||||
@switch (notification.type) {
|
||||
@case ('success') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
@case ('error') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
@case ('warning') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
@case ('info') {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
@if (notification.title) {
|
||||
<div class="toast-title">{{ notification.title }}</div>
|
||||
}
|
||||
<div class="toast-message">{{ notification.message }}</div>
|
||||
</div>
|
||||
<button class="toast-close" (click)="dismiss(notification.id); $event.stopPropagation()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-surface-100);
|
||||
border: 1px solid var(--color-surface-300);
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.toast:hover {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.toast-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-100);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-200);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-300);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-close svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Type-specific styles */
|
||||
.toast-success {
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ToastComponent {
|
||||
notificationService = inject(NotificationService);
|
||||
|
||||
dismiss(id: number) {
|
||||
this.notificationService.dismiss(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
|||
import { SidebarComponent } from '../components/sidebar/sidebar.component';
|
||||
import { StatsPanelComponent } from '../components/stats-panel/stats-panel.component';
|
||||
import { MinerSwitcherComponent } from '../components/miner-switcher/miner-switcher.component';
|
||||
import { ToastComponent } from '../components/toast/toast.component';
|
||||
import { ApiStatusComponent } from '../components/api-status/api-status.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-main-layout',
|
||||
|
|
@ -16,8 +18,12 @@ import { MinerSwitcherComponent } from '../components/miner-switcher/miner-switc
|
|||
SidebarComponent,
|
||||
StatsPanelComponent,
|
||||
MinerSwitcherComponent,
|
||||
ToastComponent,
|
||||
ApiStatusComponent,
|
||||
],
|
||||
template: `
|
||||
<app-api-status></app-api-status>
|
||||
<app-toast></app-toast>
|
||||
<div class="main-layout">
|
||||
<app-sidebar [currentRoute]="currentRoute()" (routeChange)="onRouteChange($event)"></app-sidebar>
|
||||
|
||||
|
|
@ -65,6 +71,21 @@ import { MinerSwitcherComponent } from '../components/miner-switcher/miner-switc
|
|||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.top-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
padding-left: 3.5rem; /* Space for hamburger menu */
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MainLayoutComponent implements AfterViewInit {
|
||||
|
|
|
|||
128
ui/src/app/notification.service.spec.ts
Normal file
128
ui/src/app/notification.service.spec.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
describe('NotificationService', () => {
|
||||
let service: NotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [NotificationService]
|
||||
});
|
||||
service = TestBed.inject(NotificationService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should start with no notifications', () => {
|
||||
expect(service.notifications().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should add a success notification', () => {
|
||||
service.success('Test message', 'Test title');
|
||||
|
||||
const notifications = service.notifications();
|
||||
expect(notifications.length).toBe(1);
|
||||
expect(notifications[0].type).toBe('success');
|
||||
expect(notifications[0].message).toBe('Test message');
|
||||
expect(notifications[0].title).toBe('Test title');
|
||||
});
|
||||
|
||||
it('should add an error notification', () => {
|
||||
service.error('Error message', 'Error title');
|
||||
|
||||
const notifications = service.notifications();
|
||||
expect(notifications.length).toBe(1);
|
||||
expect(notifications[0].type).toBe('error');
|
||||
expect(notifications[0].message).toBe('Error message');
|
||||
});
|
||||
|
||||
it('should add a warning notification', () => {
|
||||
service.warning('Warning message');
|
||||
|
||||
const notifications = service.notifications();
|
||||
expect(notifications.length).toBe(1);
|
||||
expect(notifications[0].type).toBe('warning');
|
||||
});
|
||||
|
||||
it('should add an info notification', () => {
|
||||
service.info('Info message');
|
||||
|
||||
const notifications = service.notifications();
|
||||
expect(notifications.length).toBe(1);
|
||||
expect(notifications[0].type).toBe('info');
|
||||
});
|
||||
|
||||
it('should assign unique IDs to notifications', () => {
|
||||
service.success('Message 1');
|
||||
service.success('Message 2');
|
||||
service.success('Message 3');
|
||||
|
||||
const notifications = service.notifications();
|
||||
const ids = notifications.map(n => n.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
|
||||
expect(uniqueIds.size).toBe(3);
|
||||
});
|
||||
|
||||
it('should dismiss a notification by ID', () => {
|
||||
service.success('Message 1');
|
||||
service.success('Message 2');
|
||||
|
||||
const firstId = service.notifications()[0].id;
|
||||
service.dismiss(firstId);
|
||||
|
||||
const remaining = service.notifications();
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0].id).not.toBe(firstId);
|
||||
});
|
||||
|
||||
it('should dismiss all notifications', () => {
|
||||
service.success('Message 1');
|
||||
service.error('Message 2');
|
||||
service.warning('Message 3');
|
||||
|
||||
expect(service.notifications().length).toBe(3);
|
||||
|
||||
service.dismissAll();
|
||||
|
||||
expect(service.notifications().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should auto-dismiss success notifications after duration', fakeAsync(() => {
|
||||
service.success('Test message', undefined, 1000);
|
||||
|
||||
expect(service.notifications().length).toBe(1);
|
||||
|
||||
tick(1000);
|
||||
|
||||
expect(service.notifications().length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should auto-dismiss error notifications after longer duration', fakeAsync(() => {
|
||||
service.error('Error message', undefined, 2000);
|
||||
|
||||
expect(service.notifications().length).toBe(1);
|
||||
|
||||
tick(1999);
|
||||
expect(service.notifications().length).toBe(1);
|
||||
|
||||
tick(1);
|
||||
expect(service.notifications().length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should handle multiple notifications with different durations', fakeAsync(() => {
|
||||
service.success('Quick', undefined, 500);
|
||||
service.error('Slow', undefined, 1500);
|
||||
|
||||
expect(service.notifications().length).toBe(2);
|
||||
|
||||
tick(500);
|
||||
expect(service.notifications().length).toBe(1);
|
||||
expect(service.notifications()[0].message).toBe('Slow');
|
||||
|
||||
tick(1000);
|
||||
expect(service.notifications().length).toBe(0);
|
||||
}));
|
||||
});
|
||||
80
ui/src/app/notification.service.ts
Normal file
80
ui/src/app/notification.service.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
message: string;
|
||||
title?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class NotificationService {
|
||||
private notificationId = 0;
|
||||
private _notifications = signal<Notification[]>([]);
|
||||
|
||||
public notifications = computed(() => this._notifications());
|
||||
|
||||
/**
|
||||
* Show a success notification
|
||||
*/
|
||||
success(message: string, title?: string, duration = 4000) {
|
||||
this.show({ type: 'success', message, title, duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error notification
|
||||
*/
|
||||
error(message: string, title?: string, duration = 6000) {
|
||||
this.show({ type: 'error', message, title, duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a warning notification
|
||||
*/
|
||||
warning(message: string, title?: string, duration = 5000) {
|
||||
this.show({ type: 'warning', message, title, duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an info notification
|
||||
*/
|
||||
info(message: string, title?: string, duration = 4000) {
|
||||
this.show({ type: 'info', message, title, duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification
|
||||
*/
|
||||
private show(notification: Omit<Notification, 'id'>) {
|
||||
const id = ++this.notificationId;
|
||||
const newNotification: Notification = { ...notification, id };
|
||||
|
||||
this._notifications.update(notifications => [...notifications, newNotification]);
|
||||
|
||||
// Auto-dismiss after duration
|
||||
if (notification.duration && notification.duration > 0) {
|
||||
setTimeout(() => this.dismiss(id), notification.duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a notification by ID
|
||||
*/
|
||||
dismiss(id: number) {
|
||||
this._notifications.update(notifications =>
|
||||
notifications.filter(n => n.id !== id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss all notifications
|
||||
*/
|
||||
dismissAll() {
|
||||
this._notifications.set([]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MinerService } from '../../miner.service';
|
||||
import { NotificationService } from '../../notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-miners',
|
||||
|
|
@ -258,6 +259,34 @@ import { MinerService } from '../../miner.service';
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.miners-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.miner-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.miner-actions {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.miner-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.installed-badge {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.system-info-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1.25rem;
|
||||
|
|
@ -301,6 +330,7 @@ import { MinerService } from '../../miner.service';
|
|||
})
|
||||
export class MinersComponent {
|
||||
private minerService = inject(MinerService);
|
||||
private notifications = inject(NotificationService);
|
||||
state = this.minerService.state;
|
||||
|
||||
installing = signal<string | null>(null);
|
||||
|
|
@ -344,10 +374,14 @@ export class MinersComponent {
|
|||
installMiner(type: string) {
|
||||
this.installing.set(type);
|
||||
this.minerService.installMiner(type).subscribe({
|
||||
next: () => this.installing.set(null),
|
||||
next: () => {
|
||||
this.installing.set(null);
|
||||
this.notifications.success(`${type} installed successfully`, 'Installation Complete');
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to install miner:', err);
|
||||
this.installing.set(null);
|
||||
this.notifications.error(`Failed to install ${type}: ${err.message || 'Unknown error'}`, 'Installation Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -355,7 +389,13 @@ export class MinersComponent {
|
|||
uninstallMiner(type: string) {
|
||||
if (confirm(`Are you sure you want to uninstall ${type}?`)) {
|
||||
this.minerService.uninstallMiner(type).subscribe({
|
||||
error: (err) => console.error('Failed to uninstall miner:', err)
|
||||
next: () => {
|
||||
this.notifications.success(`${type} uninstalled successfully`, 'Uninstall Complete');
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to uninstall miner:', err);
|
||||
this.notifications.error(`Failed to uninstall ${type}: ${err.message || 'Unknown error'}`, 'Uninstall Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MinerService } from '../../miner.service';
|
||||
import { NotificationService } from '../../notification.service';
|
||||
import { ProfileCreateComponent } from '../../profile-create.component';
|
||||
|
||||
@Component({
|
||||
|
|
@ -31,54 +32,147 @@ import { ProfileCreateComponent } from '../../profile-create.component';
|
|||
@if (profiles().length > 0) {
|
||||
<div class="profiles-grid">
|
||||
@for (profile of profiles(); track profile.id) {
|
||||
<div class="profile-card" [class.active]="isRunning(profile.id)">
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h3>{{ profile.name }}</h3>
|
||||
<span class="profile-miner">{{ profile.minerType }}</span>
|
||||
<div class="profile-card" [class.active]="isRunning(profile.id)" [class.editing]="editingProfileId() === profile.id">
|
||||
@if (editingProfileId() === profile.id) {
|
||||
<!-- Inline Edit Form -->
|
||||
<div class="edit-form">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
[value]="profile.name"
|
||||
#editName>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Pool</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
[value]="profile.config?.pool || ''"
|
||||
placeholder="stratum+tcp://pool.example.com:3333"
|
||||
#editPool>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Wallet</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-input"
|
||||
[value]="profile.config?.wallet || ''"
|
||||
placeholder="Your wallet address"
|
||||
#editWallet>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
[disabled]="savingProfile() === profile.id"
|
||||
(click)="saveProfile(profile.id, editName.value, editPool.value, editWallet.value)">
|
||||
@if (savingProfile() === profile.id) {
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
} @else {
|
||||
Save
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-outline" (click)="cancelEdit()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Normal View -->
|
||||
<div class="profile-header">
|
||||
<div class="profile-info">
|
||||
<h3>{{ profile.name }}</h3>
|
||||
<span class="profile-miner">{{ profile.minerType }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@if (isRunning(profile.id)) {
|
||||
<span class="running-badge">
|
||||
<div class="pulse-dot"></div>
|
||||
Running
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Edit profile"
|
||||
[disabled]="isRunning(profile.id)"
|
||||
(click)="startEdit(profile.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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (isRunning(profile.id)) {
|
||||
<span class="running-badge">
|
||||
<div class="pulse-dot"></div>
|
||||
Running
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="profile-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Pool</span>
|
||||
<span class="detail-value">{{ profile.config?.pool || 'Not set' }}</span>
|
||||
<div class="profile-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Pool</span>
|
||||
<span class="detail-value">{{ profile.config?.pool || 'Not set' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Wallet</span>
|
||||
<span class="detail-value">{{ profile.config?.wallet || 'Not set' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Wallet</span>
|
||||
<span class="detail-value">{{ profile.config?.wallet || 'Not set' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-actions">
|
||||
<div class="profile-actions">
|
||||
@if (!isRunning(profile.id)) {
|
||||
<button class="action-btn start" (click)="startProfile(profile.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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
</svg>
|
||||
Start
|
||||
<button
|
||||
class="action-btn start"
|
||||
[disabled]="startingProfile() === profile.id"
|
||||
(click)="startProfile(profile.id)">
|
||||
@if (startingProfile() === profile.id) {
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Starting...
|
||||
} @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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
</svg>
|
||||
Start
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="action-btn stop" (click)="stopProfile(profile.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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
||||
</svg>
|
||||
Stop
|
||||
<button
|
||||
class="action-btn stop"
|
||||
[disabled]="stoppingProfile() === profile.id"
|
||||
(click)="stopProfile(profile.id)">
|
||||
@if (stoppingProfile() === profile.id) {
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Stopping...
|
||||
} @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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
||||
</svg>
|
||||
Stop
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button class="action-btn delete" (click)="deleteProfile(profile.id)" [disabled]="isRunning(profile.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="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
|
||||
class="action-btn delete"
|
||||
(click)="deleteProfile(profile.id)"
|
||||
[disabled]="isRunning(profile.id) || deletingProfile() === profile.id">
|
||||
@if (deletingProfile() === profile.id) {
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
} @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>
|
||||
</div>
|
||||
} <!-- end of else block for normal view -->
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -168,6 +262,10 @@ import { ProfileCreateComponent } from '../../profile-create.component';
|
|||
border-color: rgb(16 185 129 / 0.3);
|
||||
}
|
||||
|
||||
.profile-card.editing {
|
||||
border-color: var(--color-accent-500);
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -175,6 +273,91 @@ import { ProfileCreateComponent } from '../../profile-create.component';
|
|||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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:not(:disabled) {
|
||||
background: rgb(37 37 66 / 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
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-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent-500);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid rgb(37 37 66 / 0.3);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: rgb(37 37 66 / 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.profile-info h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -326,13 +509,65 @@ import { ProfileCreateComponent } from '../../profile-create.component';
|
|||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profiles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ProfilesComponent {
|
||||
private minerService = inject(MinerService);
|
||||
private notifications = inject(NotificationService);
|
||||
state = this.minerService.state;
|
||||
|
||||
showCreateForm = signal(false);
|
||||
editingProfileId = signal<string | null>(null);
|
||||
|
||||
// Loading states
|
||||
startingProfile = signal<string | null>(null);
|
||||
stoppingProfile = signal<string | null>(null);
|
||||
deletingProfile = signal<string | null>(null);
|
||||
savingProfile = signal<string | null>(null);
|
||||
|
||||
profiles = () => this.state().profiles;
|
||||
|
||||
|
|
@ -340,30 +575,102 @@ export class ProfilesComponent {
|
|||
return this.state().runningMiners.some(m => m.profile_id === profileId);
|
||||
}
|
||||
|
||||
getProfileName(profileId: string): string {
|
||||
return this.state().profiles.find(p => p.id === profileId)?.name || 'Profile';
|
||||
}
|
||||
|
||||
startProfile(profileId: string) {
|
||||
const name = this.getProfileName(profileId);
|
||||
this.startingProfile.set(profileId);
|
||||
this.minerService.startMiner(profileId).subscribe({
|
||||
error: (err) => console.error('Failed to start profile:', err)
|
||||
next: () => {
|
||||
this.startingProfile.set(null);
|
||||
this.notifications.success(`${name} started successfully`, 'Miner Started');
|
||||
},
|
||||
error: (err) => {
|
||||
this.startingProfile.set(null);
|
||||
console.error('Failed to start profile:', err);
|
||||
this.notifications.error(`Failed to start ${name}: ${err.message || 'Unknown error'}`, 'Start Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stopProfile(profileId: string) {
|
||||
const miner = this.state().runningMiners.find(m => m.profile_id === profileId);
|
||||
if (miner) {
|
||||
this.stoppingProfile.set(profileId);
|
||||
this.minerService.stopMiner(miner.name).subscribe({
|
||||
error: (err) => console.error('Failed to stop miner:', err)
|
||||
next: () => {
|
||||
this.stoppingProfile.set(null);
|
||||
this.notifications.success(`${miner.name} stopped successfully`, 'Miner Stopped');
|
||||
},
|
||||
error: (err) => {
|
||||
this.stoppingProfile.set(null);
|
||||
console.error('Failed to stop miner:', err);
|
||||
this.notifications.error(`Failed to stop ${miner.name}: ${err.message || 'Unknown error'}`, 'Stop Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deleteProfile(profileId: string) {
|
||||
const name = this.getProfileName(profileId);
|
||||
if (confirm('Are you sure you want to delete this profile?')) {
|
||||
this.deletingProfile.set(profileId);
|
||||
this.minerService.deleteProfile(profileId).subscribe({
|
||||
error: (err) => console.error('Failed to delete profile:', err)
|
||||
next: () => {
|
||||
this.deletingProfile.set(null);
|
||||
this.notifications.success(`${name} deleted successfully`, 'Profile Deleted');
|
||||
},
|
||||
error: (err) => {
|
||||
this.deletingProfile.set(null);
|
||||
console.error('Failed to delete profile:', err);
|
||||
this.notifications.error(`Failed to delete ${name}: ${err.message || 'Unknown error'}`, 'Delete Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onProfileCreated() {
|
||||
this.showCreateForm.set(false);
|
||||
this.notifications.success('Profile created successfully', 'Profile Created');
|
||||
}
|
||||
|
||||
startEdit(profileId: string) {
|
||||
this.editingProfileId.set(profileId);
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.editingProfileId.set(null);
|
||||
}
|
||||
|
||||
saveProfile(profileId: string, name: string, pool: string, wallet: string) {
|
||||
const profile = this.state().profiles.find(p => p.id === profileId);
|
||||
if (!profile) return;
|
||||
|
||||
this.savingProfile.set(profileId);
|
||||
|
||||
const updatedProfile = {
|
||||
...profile,
|
||||
name: name.trim() || profile.name,
|
||||
config: {
|
||||
...profile.config,
|
||||
pool: pool.trim() || profile.config?.pool,
|
||||
wallet: wallet.trim() || profile.config?.wallet
|
||||
}
|
||||
};
|
||||
|
||||
this.minerService.updateProfile(updatedProfile).subscribe({
|
||||
next: () => {
|
||||
this.savingProfile.set(null);
|
||||
this.editingProfileId.set(null);
|
||||
this.notifications.success(`${name} updated successfully`, 'Profile Updated');
|
||||
},
|
||||
error: (err) => {
|
||||
this.savingProfile.set(null);
|
||||
console.error('Failed to update profile:', err);
|
||||
this.notifications.error(`Failed to update profile: ${err.message || 'Unknown error'}`, 'Update Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Component, inject, computed, signal, effect, OnDestroy } from '@angular
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MinerService } from '../../miner.service';
|
||||
import { NotificationService } from '../../notification.service';
|
||||
import { TerminalModalComponent } from '../../terminal-modal.component';
|
||||
|
||||
export interface WorkerStats {
|
||||
|
|
@ -55,22 +56,38 @@ export interface WorkerStats {
|
|||
</select>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
[disabled]="!selectedProfileId() || state().runningMiners.length > 0"
|
||||
[disabled]="!selectedProfileId() || state().runningMiners.length > 0 || starting()"
|
||||
(click)="startMining()">
|
||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
</svg>
|
||||
Start
|
||||
@if (starting()) {
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Starting...
|
||||
} @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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
</svg>
|
||||
Start
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (workers().length > 0) {
|
||||
<button class="btn btn-danger" (click)="stopAllWorkers()">
|
||||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
||||
</svg>
|
||||
Stop All
|
||||
<button class="btn btn-danger" [disabled]="stoppingAll()" (click)="stopAllWorkers()">
|
||||
@if (stoppingAll()) {
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Stopping...
|
||||
} @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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
||||
</svg>
|
||||
Stop All
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -130,10 +147,21 @@ export interface WorkerStats {
|
|||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Stop worker" (click)="stopWorker(worker.name)">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<button
|
||||
class="icon-btn icon-btn-danger"
|
||||
title="Stop worker"
|
||||
[disabled]="stoppingWorker() === worker.name"
|
||||
(click)="stopWorker(worker.name)">
|
||||
@if (stoppingWorker() === worker.name) {
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
} @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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -455,16 +483,69 @@ export interface WorkerStats {
|
|||
height: 64px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.actions-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.profile-selector {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workers-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.workers-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class WorkersComponent implements OnDestroy {
|
||||
private minerService = inject(MinerService);
|
||||
private notifications = inject(NotificationService);
|
||||
state = this.minerService.state;
|
||||
|
||||
selectedProfileId = signal<string | null>(null);
|
||||
terminalMinerName: string | null = null;
|
||||
firewallWarningDismissed = signal<boolean>(false);
|
||||
|
||||
// Loading states
|
||||
starting = signal(false);
|
||||
stoppingWorker = signal<string | null>(null);
|
||||
stoppingAll = signal(false);
|
||||
|
||||
firewallWarning = computed(() => {
|
||||
if (this.firewallWarningDismissed()) return false;
|
||||
const miners = this.state().runningMiners;
|
||||
|
|
@ -536,22 +617,65 @@ export class WorkersComponent implements OnDestroy {
|
|||
startMining() {
|
||||
const profileId = this.selectedProfileId();
|
||||
if (profileId) {
|
||||
const profile = this.state().profiles.find(p => p.id === profileId);
|
||||
const name = profile?.name || 'Miner';
|
||||
this.starting.set(true);
|
||||
this.minerService.startMiner(profileId).subscribe({
|
||||
error: (err) => console.error('Failed to start miner:', err)
|
||||
next: () => {
|
||||
this.starting.set(false);
|
||||
this.notifications.success(`${name} started successfully`, 'Miner Started');
|
||||
},
|
||||
error: (err) => {
|
||||
this.starting.set(false);
|
||||
console.error('Failed to start miner:', err);
|
||||
this.notifications.error(`Failed to start ${name}: ${err.message || 'Unknown error'}`, 'Start Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stopWorker(name: string) {
|
||||
this.stoppingWorker.set(name);
|
||||
this.minerService.stopMiner(name).subscribe({
|
||||
error: (err) => console.error(`Failed to stop ${name}:`, err)
|
||||
next: () => {
|
||||
this.stoppingWorker.set(null);
|
||||
this.notifications.success(`${name} stopped`, 'Worker Stopped');
|
||||
},
|
||||
error: (err) => {
|
||||
this.stoppingWorker.set(null);
|
||||
console.error(`Failed to stop ${name}:`, err);
|
||||
this.notifications.error(`Failed to stop ${name}: ${err.message || 'Unknown error'}`, 'Stop Failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stopAllWorkers() {
|
||||
const workerCount = this.workers().length;
|
||||
let stoppedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
this.stoppingAll.set(true);
|
||||
this.workers().forEach(w => {
|
||||
this.minerService.stopMiner(w.name).subscribe({
|
||||
error: (err) => console.error(`Failed to stop ${w.name}:`, err)
|
||||
next: () => {
|
||||
stoppedCount++;
|
||||
if (stoppedCount + errorCount === workerCount) {
|
||||
this.stoppingAll.set(false);
|
||||
if (errorCount === 0) {
|
||||
this.notifications.success(`All ${stoppedCount} workers stopped`, 'All Workers Stopped');
|
||||
} else {
|
||||
this.notifications.warning(`Stopped ${stoppedCount} workers, ${errorCount} failed`, 'Partial Stop');
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Failed to stop ${w.name}:`, err);
|
||||
errorCount++;
|
||||
if (stoppedCount + errorCount === workerCount) {
|
||||
this.stoppingAll.set(false);
|
||||
this.notifications.warning(`Stopped ${stoppedCount} workers, ${errorCount} failed`, 'Partial Stop');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue