diff --git a/code/Executable/DependencyInjection/LoggingDI.cs b/code/Executable/DependencyInjection/LoggingDI.cs index 364e5f9b..6feea8fd 100644 --- a/code/Executable/DependencyInjection/LoggingDI.cs +++ b/code/Executable/DependencyInjection/LoggingDI.cs @@ -41,8 +41,6 @@ public static class LoggingDI List instanceNames = [InstanceType.Sonarr.ToString(), InstanceType.Radarr.ToString(), InstanceType.Lidarr.ToString()]; int arrPadding = instanceNames.Max(x => x.Length) + 2; - // Log level is controlled by the LoggingConfigManager's level switch - // Apply padding values to templates string consoleTemplate = consoleOutputTemplate .Replace("CAT_PAD", catPadding.ToString()) diff --git a/code/UI/src/app/app.routes.ts b/code/UI/src/app/app.routes.ts index 5e72232a..b9227638 100644 --- a/code/UI/src/app/app.routes.ts +++ b/code/UI/src/app/app.routes.ts @@ -1,6 +1,8 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ - { path: '', redirectTo: 'logs', pathMatch: 'full' }, - { path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) } + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) }, + { path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) }, + { path: 'settings', loadComponent: () => import('./settings/settings-page/settings-page.component').then(m => m.SettingsPageComponent) } ]; diff --git a/code/UI/src/app/core/models/signalr.models.ts b/code/UI/src/app/core/models/signalr.models.ts new file mode 100644 index 00000000..665bb4c9 --- /dev/null +++ b/code/UI/src/app/core/models/signalr.models.ts @@ -0,0 +1,32 @@ +/** + * Models for SignalR connections and messages + */ + +/** + * Configuration options for a SignalR hub connection + */ +export interface SignalRHubConfig { + /** URL to the SignalR hub endpoint */ + hubUrl: string; + /** Maximum number of reconnection attempts (0 for infinite) */ + maxReconnectAttempts: number; + /** Initial delay between reconnection attempts in milliseconds (will be subject to backoff) */ + reconnectDelayMs: number; + /** Maximum size of the message buffer */ + bufferSize: number; + /** Interval in milliseconds to check connection health (0 to disable) */ + healthCheckIntervalMs: number; +} + +/** + * Standard log entry message format received from the server + */ +export interface LogEntry { + timestamp: Date; + level: string; + message: string; + exception?: string; + category?: string; + jobName?: string; + instanceName?: string; +} diff --git a/code/UI/src/app/core/services/base-signalr.service.ts b/code/UI/src/app/core/services/base-signalr.service.ts new file mode 100644 index 00000000..46260408 --- /dev/null +++ b/code/UI/src/app/core/services/base-signalr.service.ts @@ -0,0 +1,269 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import * as signalR from '@microsoft/signalr'; +import { SignalRHubConfig } from '../models/signalr.models'; + +/** + * Base service for SignalR hub connections. + * Provides common functionality for connecting to, monitoring, and managing SignalR hubs. + * + * @typeParam T - The type of messages that will be received from this hub + */ +@Injectable() +export abstract class BaseSignalRService implements OnDestroy { + protected hubConnection!: signalR.HubConnection; + protected connectionStatusSubject = new BehaviorSubject(false); + protected messageSubject = new BehaviorSubject([]); + protected destroy$ = new Subject(); + protected reconnectAttempts = 0; + protected connectionHealthCheckInterval: any; + protected messageBuffer: T[] = []; + + /** + * Initialize a base SignalR hub connection service + * + * @param config - Configuration for the hub connection + * @param messageEventName - The name of the event that delivers messages from the server + */ + constructor( + protected config: SignalRHubConfig, + protected messageEventName: string + ) {} + + /** + * Start the SignalR connection to the hub + * @returns Promise that resolves when the connection is established + */ + public startConnection(): Promise { + if (this.hubConnection && + this.hubConnection.state !== signalR.HubConnectionState.Disconnected) { + return Promise.resolve(); + } + + // Build a new connection if needed + if (!this.hubConnection) { + this.hubConnection = new signalR.HubConnectionBuilder() + .withUrl(this.config.hubUrl) + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => { + if (this.config.maxReconnectAttempts > 0 && + retryContext.previousRetryCount >= this.config.maxReconnectAttempts) { + return null; // Stop trying after max attempts + } + + // Implement exponential backoff with a maximum delay of 30 seconds + return Math.min( + this.config.reconnectDelayMs * Math.pow(2, retryContext.previousRetryCount), + 30000 + ); + } + }) + .build(); + + this.registerSignalREvents(); + } + + // Start health check interval if configured + this.startHealthCheck(); + + return this.hubConnection.start() + .then(() => { + console.log(`SignalR connection started to ${this.config.hubUrl}`); + this.connectionStatusSubject.next(true); + this.reconnectAttempts = 0; + this.onConnectionEstablished(); + }) + .catch(err => { + console.error(`Error connecting to ${this.config.hubUrl}:`, err); + this.connectionStatusSubject.next(false); + + // Always try to reconnect, optionally limited by maxReconnectAttempts + const infiniteReconnect = this.config.maxReconnectAttempts === 0; + if (infiniteReconnect || this.reconnectAttempts < this.config.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = Math.min( + this.config.reconnectDelayMs * Math.pow(1.5, this.reconnectAttempts), + 30000 + ); + + console.log(`Attempting to reconnect (${this.reconnectAttempts}) in ${delay}ms...`); + setTimeout(() => this.startConnection(), delay); + } + + throw err; + }); + } + + /** + * Stop the SignalR connection + * @returns Promise that resolves when the connection is stopped + */ + public stopConnection(): Promise { + this.stopHealthCheck(); + + if (!this.hubConnection) { + return Promise.resolve(); + } + + return this.hubConnection.stop() + .then(() => { + console.log(`SignalR connection to ${this.config.hubUrl} stopped`); + this.connectionStatusSubject.next(false); + }) + .catch(err => { + console.error(`Error stopping connection to ${this.config.hubUrl}:`, err); + throw err; + }); + } + + /** + * Register event handlers for SignalR events + */ + protected registerSignalREvents(): void { + // Handle incoming messages + this.hubConnection.on(this.messageEventName, (message: T) => { + this.processMessage(message); + }); + + // Handle reconnection events + this.hubConnection.onreconnected(() => { + console.log(`SignalR connection reconnected to ${this.config.hubUrl}`); + this.connectionStatusSubject.next(true); + this.reconnectAttempts = 0; + this.onConnectionEstablished(); + }); + + this.hubConnection.onreconnecting(() => { + console.log(`SignalR connection reconnecting to ${this.config.hubUrl}...`); + this.connectionStatusSubject.next(false); + }); + + this.hubConnection.onclose(() => { + console.log(`SignalR connection to ${this.config.hubUrl} closed`); + this.connectionStatusSubject.next(false); + + // Try to reconnect if the connection was closed unexpectedly + if (this.shouldAttemptReconnect()) { + this.startConnection(); + } + }); + } + + /** + * Start the health check timer to periodically verify connection status + */ + protected startHealthCheck(): void { + this.stopHealthCheck(); + + if (this.config.healthCheckIntervalMs > 0) { + this.connectionHealthCheckInterval = setInterval(() => { + this.checkConnectionHealth(); + }, this.config.healthCheckIntervalMs); + } + } + + /** + * Stop the health check timer + */ + protected stopHealthCheck(): void { + if (this.connectionHealthCheckInterval) { + clearInterval(this.connectionHealthCheckInterval); + this.connectionHealthCheckInterval = null; + } + } + + /** + * Check the health of the connection and attempt to reconnect if needed + */ + protected checkConnectionHealth(): void { + if (!this.hubConnection || + this.hubConnection.state === signalR.HubConnectionState.Disconnected) { + console.log('Health check detected disconnected state, attempting to reconnect...'); + this.startConnection(); + } + } + + /** + * Process a message received from the hub + * @param message - The message received from the hub + */ + protected processMessage(message: T): void { + // Add to buffer + this.addToBuffer(message); + + // Update the subject with current messages + const currentMessages = this.messageSubject.value; + this.messageSubject.next([...currentMessages, message]); + } + + /** + * Add a message to the buffer + * @param message - The message to add to the buffer + */ + protected addToBuffer(message: T): void { + this.messageBuffer.push(message); + + // Trim buffer if it exceeds the limit + if (this.messageBuffer.length > this.config.bufferSize) { + this.messageBuffer.shift(); + } + } + + /** + * Called when a connection is established or re-established + * Override in derived classes to perform hub-specific initialization + */ + protected onConnectionEstablished(): void { + // Override in derived classes + } + + /** + * Determine if a reconnection attempt should be made + * @returns True if a reconnection attempt should be made + */ + protected shouldAttemptReconnect(): boolean { + // By default, always try to reconnect unless max attempts reached + return this.config.maxReconnectAttempts === 0 || + this.reconnectAttempts < this.config.maxReconnectAttempts; + } + + /** + * Force a reconnection attempt, even if max attempts have been reached + */ + public forceReconnect(): void { + this.reconnectAttempts = 0; + this.startConnection(); + } + + /** + * Get the buffered messages + * @returns A copy of the message buffer + */ + public getBufferedMessages(): T[] { + return [...this.messageBuffer]; + } + + /** + * Get messages as an observable + * @returns An observable that emits the current messages and all future messages + */ + public getMessages(): Observable { + return this.messageSubject.asObservable(); + } + + /** + * Get connection status as an observable + * @returns An observable that emits the current connection status and all future status changes + */ + public getConnectionStatus(): Observable { + return this.connectionStatusSubject.asObservable(); + } + + /** + * Clean up resources + */ + ngOnDestroy(): void { + this.stopHealthCheck(); + this.stopConnection(); + } +} diff --git a/code/UI/src/app/core/services/log-hub.service.ts b/code/UI/src/app/core/services/log-hub.service.ts new file mode 100644 index 00000000..39eb0e14 --- /dev/null +++ b/code/UI/src/app/core/services/log-hub.service.ts @@ -0,0 +1,61 @@ +import { Injectable, inject } from '@angular/core'; +import { BaseSignalRService } from './base-signalr.service'; +import { LogEntry, SignalRHubConfig } from '../models/signalr.models'; +// Note: We'll define a fallback API URL if environment is not available yet +const API_URL = 'http://localhost:5000'; + +/** + * Service for connecting to the logs SignalR hub + */ +@Injectable({ + providedIn: 'root' +}) +export class LogHubService extends BaseSignalRService { + constructor() { + // Default configuration for the logs hub + const config: SignalRHubConfig = { + hubUrl: `${API_URL}/api/hubs/logs`, + maxReconnectAttempts: 0, // Infinite reconnection attempts + reconnectDelayMs: 2000, + bufferSize: 100, + healthCheckIntervalMs: 30000 // Check connection every 30 seconds + }; + + super(config, 'ReceiveLog'); + } + + /** + * Request recent logs from the server + */ + public requestRecentLogs(): void { + if (this.hubConnection && + this.hubConnection.state === signalR.HubConnectionState.Connected) { + this.hubConnection.invoke('RequestRecentLogs') + .catch(err => console.error('Error while requesting recent logs:', err)); + } + } + + /** + * Override to request recent logs when connection is established + */ + protected override onConnectionEstablished(): void { + this.requestRecentLogs(); + } + + /** + * Get the buffered logs + */ + public getBufferedLogs(): LogEntry[] { + return this.getBufferedMessages(); + } + + /** + * Get logs as an observable + */ + public getLogs() { + return this.getMessages(); + } +} + +// Fix missing signalR import +import * as signalR from '@microsoft/signalr'; diff --git a/code/UI/src/app/core/services/system-hub.service.ts b/code/UI/src/app/core/services/system-hub.service.ts new file mode 100644 index 00000000..6463020f --- /dev/null +++ b/code/UI/src/app/core/services/system-hub.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { BaseSignalRService } from './base-signalr.service'; +import { SignalRHubConfig } from '../models/signalr.models'; +import { environment } from '../../../environments/environment'; +import * as signalR from '@microsoft/signalr'; + +/** + * System status message from the system hub + */ +export interface SystemStatus { + timestamp: Date; + status: 'online' | 'offline' | 'degraded'; + message: string; + serverVersion?: string; + components?: { + name: string; + status: 'online' | 'offline' | 'degraded'; + }[]; +} + +/** + * Service for connecting to the system monitoring SignalR hub + */ +@Injectable({ + providedIn: 'root' +}) +export class SystemHubService extends BaseSignalRService { + constructor() { + // Default configuration for the system hub + const config: SignalRHubConfig = { + hubUrl: environment.apiUrl ? `${environment.apiUrl}/api/hubs/system` : 'http://localhost:5000/api/hubs/system', + maxReconnectAttempts: 0, // Infinite reconnection attempts + reconnectDelayMs: 2000, + bufferSize: 10, // Only need a few status updates + healthCheckIntervalMs: 30000 // Check connection every 30 seconds + }; + + super(config, 'ReceiveSystemStatus'); + } + + /** + * Request the current system status + */ + public requestSystemStatus(): void { + if (this.hubConnection && + this.hubConnection.state === signalR.HubConnectionState.Connected) { + this.hubConnection.invoke('RequestSystemStatus') + .catch(err => console.error('Error while requesting system status:', err)); + } + } + + /** + * Override to request system status when connection is established + */ + protected override onConnectionEstablished(): void { + this.requestSystemStatus(); + } + + /** + * Get the latest system status + */ + public getLatestStatus(): SystemStatus | undefined { + const messages = this.getBufferedMessages(); + return messages.length > 0 ? messages[messages.length - 1] : undefined; + } +} diff --git a/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.html b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.html new file mode 100644 index 00000000..c64e0303 --- /dev/null +++ b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.html @@ -0,0 +1,38 @@ +
+

Dashboard

+ +
+
+ +
+

Monitor your application logs and error statuses.

+ +
+
+
+ +
+ +
+

All systems operational

+ +
+
+
+ +
+ +
+

Your application is up to date.

+ +
+
+
+
+
diff --git a/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.scss b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.scss new file mode 100644 index 00000000..9881a64e --- /dev/null +++ b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.scss @@ -0,0 +1,37 @@ +.dashboard-container { + padding: 1.5rem; + + h1 { + margin-top: 0; + margin-bottom: 1.5rem; + font-weight: 600; + } + + .card-content { + min-height: 100px; + display: flex; + flex-direction: column; + justify-content: space-between; + } + + .card-footer { + margin-top: 1rem; + display: flex; + justify-content: flex-end; + } + + ::ng-deep .p-card { + height: 100%; + + .p-card-body { + height: 100%; + display: flex; + flex-direction: column; + } + + .p-card-content { + flex-grow: 1; + padding-bottom: 0; + } + } +} \ No newline at end of file diff --git a/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.spec.ts b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.spec.ts new file mode 100644 index 00000000..28db1f18 --- /dev/null +++ b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DashboardPageComponent } from './dashboard-page.component'; + +describe('DashboardPageComponent', () => { + let component: DashboardPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DashboardPageComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(DashboardPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.ts b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.ts new file mode 100644 index 00000000..f97c59dc --- /dev/null +++ b/code/UI/src/app/dashboard/dashboard-page/dashboard-page.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +// PrimeNG Components +import { CardModule } from 'primeng/card'; +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'app-dashboard-page', + standalone: true, + imports: [ + CommonModule, + CardModule, + ButtonModule + ], + templateUrl: './dashboard-page.component.html', + styleUrl: './dashboard-page.component.scss' +}) +export class DashboardPageComponent { + +} diff --git a/code/UI/src/app/layout/main-layout/main-layout.component.html b/code/UI/src/app/layout/main-layout/main-layout.component.html index 3ad3bfde..4c1ea825 100644 --- a/code/UI/src/app/layout/main-layout/main-layout.component.html +++ b/code/UI/src/app/layout/main-layout/main-layout.component.html @@ -2,6 +2,12 @@
+
CleanupErr
@@ -15,10 +21,51 @@
- -
-
- + + + + + +
+ + + + +
+
+ +
diff --git a/code/UI/src/app/layout/main-layout/main-layout.component.scss b/code/UI/src/app/layout/main-layout/main-layout.component.scss index 51cfd2df..2633f36e 100644 --- a/code/UI/src/app/layout/main-layout/main-layout.component.scss +++ b/code/UI/src/app/layout/main-layout/main-layout.component.scss @@ -4,7 +4,7 @@ flex-direction: column; background-color: var(--surface-ground); color: var(--text-color); - transition: background-color 0.2s, color 0.2s; + transition: all 0.3s ease; &.dark-mode { // Dark mode variables are applied via documentElement.classList.add('dark') @@ -13,42 +13,133 @@ } .top-bar { - background-color: var(--surface-card); + background-color: var(--surface-card) !important; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.06); - padding: 0.5rem 1.5rem; - border-bottom: 1px solid var(--surface-border); position: sticky; top: 0; - z-index: 999; + z-index: 1000; + } - .logo-container { + .logo-container { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .app-name { + font-size: 1.25rem; + font-weight: 600; + letter-spacing: 0.5px; + } + + .theme-switch { + display: flex; + align-items: center; + gap: 0.5rem; + + .pi-sun { + color: var(--yellow-500); + } + + .pi-moon { + color: var(--indigo-300); + } + } + + .layout-main { + display: flex; + flex: 1; + position: relative; + } + + .sidebar-toggle-mobile { + display: none; + margin-right: 0.5rem; + } + + // Desktop Sidebar + .sidebar { + width: 240px; + background-color: var(--surface-overlay); + border-right: 1px solid var(--surface-border); + height: calc(100vh - 4rem); // Adjust based on your toolbar height + position: sticky; + top: 4rem; // Toolbar height + z-index: 998; + transition: width 0.3s; + + .sidebar-content { display: flex; - align-items: center; + flex-direction: column; + height: 100%; - .app-name { - font-size: 1.5rem; - font-weight: 600; - color: var(--primary-color); - margin-left: 0.5rem; + .sidebar-logo { + display: flex; + justify-content: center; + padding: 1.5rem 0; + border-bottom: 1px solid var(--surface-border); + + .app-logo { + width: 40px; + height: 40px; + background-color: var(--primary-color); + color: var(--primary-color-text); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 18px; + } } } + } - .theme-switch { - display: flex; - align-items: center; - gap: 0.5rem; + .menu-list { + list-style: none; + padding: 0.5rem; + margin: 0; + + .menu-item { + margin-bottom: 0.25rem; - i { - font-size: 1.25rem; - color: var(--text-color-secondary); - } - - .pi-sun { - color: var(--yellow-500); - } - - .pi-moon { - color: var(--blue-500); + a { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + border-radius: 8px; + color: var(--text-color); + text-decoration: none; + transition: all 0.2s; + gap: 0.75rem; + + &:hover { + background-color: var(--surface-hover); + } + + &.active-menu-link { + background-color: var(--primary-100); + color: var(--primary-color); + font-weight: 500; + + i { + color: var(--primary-color); + } + } + + i { + font-size: 1.2rem; + color: var(--text-color-secondary); + } + + .badge { + margin-left: auto; + background-color: var(--primary-color); + color: var(--primary-color-text); + padding: 0.25rem 0.5rem; + border-radius: 8px; + font-size: 0.75rem; + } } } } @@ -57,8 +148,10 @@ flex: 1; display: flex; flex-direction: column; + overflow-y: auto; + min-height: calc(100vh - 4rem); padding: 1.5rem; - + .layout-content-inner { flex: 1; border-radius: var(--border-radius); diff --git a/code/UI/src/app/layout/main-layout/main-layout.component.ts b/code/UI/src/app/layout/main-layout/main-layout.component.ts index 7f8c89a9..7d78c9a0 100644 --- a/code/UI/src/app/layout/main-layout/main-layout.component.ts +++ b/code/UI/src/app/layout/main-layout/main-layout.component.ts @@ -1,5 +1,5 @@ import { Component, inject, signal } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; import { CommonModule } from '@angular/common'; // PrimeNG Imports @@ -7,6 +7,15 @@ import { ButtonModule } from 'primeng/button'; import { ToolbarModule } from 'primeng/toolbar'; import { InputSwitchModule } from 'primeng/inputswitch'; import { FormsModule } from '@angular/forms'; +import { MenuModule } from 'primeng/menu'; +import { SidebarModule } from 'primeng/sidebar'; + +interface MenuItem { + label: string; + icon: string; + route: string; + badge?: string; +} @Component({ selector: 'app-main-layout', @@ -14,16 +23,28 @@ import { FormsModule } from '@angular/forms'; imports: [ CommonModule, RouterOutlet, + RouterLink, + RouterLinkActive, ButtonModule, ToolbarModule, InputSwitchModule, - FormsModule + FormsModule, + MenuModule, + SidebarModule ], templateUrl: './main-layout.component.html', styleUrl: './main-layout.component.scss' }) export class MainLayoutComponent { darkMode = signal(false); + menuItems: MenuItem[] = [ + { label: 'Dashboard', icon: 'pi pi-home', route: '/dashboard' }, + { label: 'Logs', icon: 'pi pi-list', route: '/logs' }, + { label: 'Settings', icon: 'pi pi-cog', route: '/settings' } + ]; + + // Mobile menu state + mobileSidebarVisible = signal(false); constructor() { // Initialize theme based on system preference @@ -45,4 +66,8 @@ export class MainLayoutComponent { documentElement.style.colorScheme = 'light'; } } + + toggleMobileSidebar(): void { + this.mobileSidebarVisible.update(value => !value); + } } diff --git a/code/UI/src/app/logging/logs-viewer/logs-viewer.component.ts b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.ts index 2b4ff01d..08c27e75 100644 --- a/code/UI/src/app/logging/logs-viewer/logs-viewer.component.ts +++ b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.ts @@ -14,8 +14,9 @@ import { ToolbarModule } from 'primeng/toolbar'; import { TooltipModule } from 'primeng/tooltip'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; -// Services -import { LogEntry, SignalrService } from '../../core/services/signalr.service'; +// Services & Models +import { LogHubService } from '../../core/services/log-hub.service'; +import { LogEntry } from '../../core/models/signalr.models'; @Component({ selector: 'app-logs-viewer', @@ -34,12 +35,12 @@ import { LogEntry, SignalrService } from '../../core/services/signalr.service'; TooltipModule, ProgressSpinnerModule ], - providers: [SignalrService], + providers: [LogHubService], templateUrl: './logs-viewer.component.html', styleUrl: './logs-viewer.component.scss' }) export class LogsViewerComponent implements OnInit, OnDestroy { - private signalrService = inject(SignalrService); + private logHubService = inject(LogHubService); private destroy$ = new Subject(); // Signals for reactive state @@ -87,18 +88,18 @@ export class LogsViewerComponent implements OnInit, OnDestroy { ngOnInit(): void { // Connect to SignalR hub - this.signalrService.startConnection() - .catch((error: Error) => console.error('Failed to connect to SignalR hub:', error)); + this.logHubService.startConnection() + .catch((error: Error) => console.error('Failed to connect to log hub:', error)); // Subscribe to logs - this.signalrService.getLogs() + this.logHubService.getLogs() .pipe(takeUntil(this.destroy$)) .subscribe((logs: LogEntry[]) => { this.logs.set(logs); }); // Subscribe to connection status - this.signalrService.getConnectionStatus() + this.logHubService.getConnectionStatus() .pipe(takeUntil(this.destroy$)) .subscribe((status: boolean) => { this.isConnected.set(status); @@ -150,7 +151,7 @@ export class LogsViewerComponent implements OnInit, OnDestroy { } refresh(): void { - this.signalrService.requestRecentLogs(); + this.logHubService.requestRecentLogs(); } hasJobInfo(): boolean { diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.html b/code/UI/src/app/settings/settings-page/settings-page.component.html new file mode 100644 index 00000000..fa47ad2e --- /dev/null +++ b/code/UI/src/app/settings/settings-page/settings-page.component.html @@ -0,0 +1,41 @@ +
+

Settings

+ + + +
+ + +
+
+ + +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ +
+ + +
+
+
+ +
+ + +
+
diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.scss b/code/UI/src/app/settings/settings-page/settings-page.component.scss new file mode 100644 index 00000000..635cb181 --- /dev/null +++ b/code/UI/src/app/settings/settings-page/settings-page.component.scss @@ -0,0 +1,32 @@ +.settings-container { + padding: 1.5rem; + + h1 { + margin-top: 0; + margin-bottom: 1.5rem; + font-weight: 600; + } + + .field-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + + label { + font-weight: 500; + margin-right: 1rem; + } + + input { + max-width: 300px; + } + } + + .button-container { + margin-top: 1.5rem; + display: flex; + gap: 0.5rem; + justify-content: flex-end; + } +} \ No newline at end of file diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.spec.ts b/code/UI/src/app/settings/settings-page/settings-page.component.spec.ts new file mode 100644 index 00000000..aac536f8 --- /dev/null +++ b/code/UI/src/app/settings/settings-page/settings-page.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SettingsPageComponent } from './settings-page.component'; + +describe('SettingsPageComponent', () => { + let component: SettingsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SettingsPageComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SettingsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/code/UI/src/app/settings/settings-page/settings-page.component.ts b/code/UI/src/app/settings/settings-page/settings-page.component.ts new file mode 100644 index 00000000..fba8ee1d --- /dev/null +++ b/code/UI/src/app/settings/settings-page/settings-page.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +// PrimeNG Components +import { CardModule } from 'primeng/card'; +import { InputTextModule } from 'primeng/inputtext'; +import { InputSwitchModule } from 'primeng/inputswitch'; +import { ButtonModule } from 'primeng/button'; +import { AccordionModule } from 'primeng/accordion'; + +@Component({ + selector: 'app-settings-page', + standalone: true, + imports: [ + CommonModule, + FormsModule, + CardModule, + InputTextModule, + InputSwitchModule, + ButtonModule, + AccordionModule + ], + templateUrl: './settings-page.component.html', + styleUrl: './settings-page.component.scss' +}) +export class SettingsPageComponent { + // Sample settings + apiUrl = 'http://localhost:5000'; + enableLogs = true; + enableNotifications = true; + autoRefresh = false; + refreshInterval = 30; +} diff --git a/code/UI/src/environments/environment.prod.ts b/code/UI/src/environments/environment.prod.ts new file mode 100644 index 00000000..d8aaeacc --- /dev/null +++ b/code/UI/src/environments/environment.prod.ts @@ -0,0 +1,7 @@ +/** + * Environment configuration for production + */ +export const environment = { + production: true, + apiUrl: '' // Set this dynamically or through build configuration +}; diff --git a/code/UI/src/environments/environment.ts b/code/UI/src/environments/environment.ts new file mode 100644 index 00000000..05c00ff5 --- /dev/null +++ b/code/UI/src/environments/environment.ts @@ -0,0 +1,7 @@ +/** + * Environment configuration for development + */ +export const environment = { + production: false, + apiUrl: 'http://localhost:5000' +};