diff --git a/ui/src/app/app.spec.ts b/ui/src/app/app.spec.ts index 4002d38..b998c4b 100644 --- a/ui/src/app/app.spec.ts +++ b/ui/src/app/app.spec.ts @@ -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'); - }); }); diff --git a/ui/src/app/components/api-status/api-status.component.ts b/ui/src/app/components/api-status/api-status.component.ts new file mode 100644 index 0000000..d859610 --- /dev/null +++ b/ui/src/app/components/api-status/api-status.component.ts @@ -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()) { +
+ +
+ } + `, + 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(); + } +} diff --git a/ui/src/app/components/sidebar/sidebar.component.spec.ts b/ui/src/app/components/sidebar/sidebar.component.spec.ts new file mode 100644 index 0000000..1de9d19 --- /dev/null +++ b/ui/src/app/components/sidebar/sidebar.component.spec.ts @@ -0,0 +1,122 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SidebarComponent } from './sidebar.component'; + +describe('SidebarComponent', () => { + let component: SidebarComponent; + let fixture: ComponentFixture; + + 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); + }); +}); diff --git a/ui/src/app/components/sidebar/sidebar.component.ts b/ui/src/app/components/sidebar/sidebar.component.ts index 10924e4..c74db8e 100644 --- a/ui/src/app/components/sidebar/sidebar.component.ts +++ b/ui/src/app/components/sidebar/sidebar.component.ts @@ -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: ` -