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:
snider 2025-12-31 00:04:57 +00:00
parent d51ba83efa
commit e3122bb41e
12 changed files with 1407 additions and 80 deletions

View file

@ -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');
});
});

View 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();
}
}

View 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);
});
});

View file

@ -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();
}
}

View 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();
});
});
});

View 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);
}
}

View file

@ -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 {

View 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);
}));
});

View 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([]);
}
}

View file

@ -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');
}
});
}
}

View file

@ -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');
}
});
}
}

View file

@ -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');
}
}
});
});
}