From 8721bc411e12aaed850c8013fda24eea24c04f15 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 20 May 2025 10:35:30 +0300 Subject: [PATCH] #26 --- code/Executable/DependencyInjection/ApiDI.cs | 7 +- code/Executable/Program.cs | 12 ++ code/UI/package-lock.json | 9 + code/UI/package.json | 3 +- code/UI/src/app/app.config.ts | 23 ++- code/UI/src/app/app.routes.ts | 5 +- .../src/app/core/services/signalr.service.ts | 172 +++++++++++++++++- .../logs-viewer/logs-viewer.component.html | 115 ++++++++++++ .../logs-viewer/logs-viewer.component.scss | 137 ++++++++++++++ .../logs-viewer/logs-viewer.component.spec.ts | 23 +++ .../logs-viewer/logs-viewer.component.ts | 5 +- 11 files changed, 497 insertions(+), 14 deletions(-) create mode 100644 code/UI/src/app/logging/logs-viewer/logs-viewer.component.html create mode 100644 code/UI/src/app/logging/logs-viewer/logs-viewer.component.scss create mode 100644 code/UI/src/app/logging/logs-viewer/logs-viewer.component.spec.ts diff --git a/code/Executable/DependencyInjection/ApiDI.cs b/code/Executable/DependencyInjection/ApiDI.cs index 5ba9216c..594403b9 100644 --- a/code/Executable/DependencyInjection/ApiDI.cs +++ b/code/Executable/DependencyInjection/ApiDI.cs @@ -42,6 +42,9 @@ public static class ApiDI public static WebApplication ConfigureApi(this WebApplication app) { + app.UseCors("SignalRPolicy"); + app.UseRouting(); + // Configure middleware pipeline for API if (app.Environment.IsDevelopment()) { @@ -59,8 +62,8 @@ public static class ApiDI app.MapControllers(); // Map SignalR hubs - app.MapHub("/hubs/health"); - app.MapHub("/hubs/logs"); + app.MapHub("/api/hubs/health"); + app.MapHub("/api/hubs/logs"); return app; } diff --git a/code/Executable/Program.cs b/code/Executable/Program.cs index 5e284995..588393bd 100644 --- a/code/Executable/Program.cs +++ b/code/Executable/Program.cs @@ -10,6 +10,18 @@ builder.Services .AddInfrastructure(builder.Configuration) .AddApiServices(); +// Add CORS before SignalR +builder.Services.AddCors(options => +{ + options.AddPolicy("SignalRPolicy", policy => + { + policy.WithOrigins("http://localhost:4200") // Your Angular URL + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); // Required for SignalR auth + }); +}); + // Register SignalR - ensure this is before logging initialization builder.Services.AddSignalR(); diff --git a/code/UI/package-lock.json b/code/UI/package-lock.json index 90d337fa..faadaf60 100644 --- a/code/UI/package-lock.json +++ b/code/UI/package-lock.json @@ -18,6 +18,7 @@ "@angular/service-worker": "^19.2.0", "@microsoft/signalr": "^8.0.7", "@ngrx/signals": "^19.2.0", + "@primeng/themes": "^19.1.3", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^19.1.3", @@ -4422,6 +4423,14 @@ "node": ">=14" } }, + "node_modules/@primeng/themes": { + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@primeng/themes/-/themes-19.1.3.tgz", + "integrity": "sha512-y4VryHHUTPWlmfR56NBANC0QPIxEngTUE/J3pGs4SJquq1n5EE/U16dxa1qW/wXqLF3jn3l/AO/4KZqGj5UuAA==", + "dependencies": { + "@primeuix/styled": "^0.3.2" + } + }, "node_modules/@primeuix/styled": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.3.2.tgz", diff --git a/code/UI/package.json b/code/UI/package.json index 78057c4e..152132e1 100644 --- a/code/UI/package.json +++ b/code/UI/package.json @@ -20,6 +20,7 @@ "@angular/service-worker": "^19.2.0", "@microsoft/signalr": "^8.0.7", "@ngrx/signals": "^19.2.0", + "@primeng/themes": "^19.1.3", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^19.1.3", @@ -40,4 +41,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.7.2" } -} \ No newline at end of file +} diff --git a/code/UI/src/app/app.config.ts b/code/UI/src/app/app.config.ts index eb929b36..eb27f1d4 100644 --- a/code/UI/src/app/app.config.ts +++ b/code/UI/src/app/app.config.ts @@ -1,12 +1,27 @@ import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { providePrimeNG } from 'primeng/config'; +import Aura from '@primeng/themes/aura'; import { routes } from './app.routes'; import { provideServiceWorker } from '@angular/service-worker'; export const appConfig: ApplicationConfig = { - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideServiceWorker('ngsw-worker.js', { - enabled: !isDevMode(), - registrationStrategy: 'registerWhenStable:30000' - })] + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideHttpClient(withInterceptorsFromDi()), + provideAnimationsAsync(), + providePrimeNG({ + theme: { + preset: Aura + } + }), + provideServiceWorker('ngsw-worker.js', { + enabled: !isDevMode(), + registrationStrategy: 'registerWhenStable:30000' + }) + ] }; diff --git a/code/UI/src/app/app.routes.ts b/code/UI/src/app/app.routes.ts index dc39edb5..5e72232a 100644 --- a/code/UI/src/app/app.routes.ts +++ b/code/UI/src/app/app.routes.ts @@ -1,3 +1,6 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', redirectTo: 'logs', pathMatch: 'full' }, + { path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) } +]; diff --git a/code/UI/src/app/core/services/signalr.service.ts b/code/UI/src/app/core/services/signalr.service.ts index 01b32315..7b328f89 100644 --- a/code/UI/src/app/core/services/signalr.service.ts +++ b/code/UI/src/app/core/services/signalr.service.ts @@ -1,9 +1,177 @@ -import { Injectable } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import * as signalR from '@microsoft/signalr'; + +// Define the LogEntry interface locally to avoid dependency issues +export interface LogEntry { + timestamp: Date; + level: string; + message: string; + exception?: string; + category?: string; + jobName?: string; + instanceName?: string; +} @Injectable({ providedIn: 'root' }) -export class SignalrService { +export class SignalrService implements OnDestroy { + private hubConnection!: signalR.HubConnection; + private hubUrl = 'http://localhost:5000/api/hubs/logs'; + private logSubject = new BehaviorSubject([]); + private connectionStatusSubject = new BehaviorSubject(false); + private destroy$ = new Subject(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelayMs = 2000; + private bufferSize = 100; + private logBuffer: LogEntry[] = []; constructor() { } + + /** + * Start the SignalR connection to the hub + */ + public startConnection(): Promise { + if (this.hubConnection) { + return Promise.resolve(); + } + + this.hubConnection = new signalR.HubConnectionBuilder() + .withUrl(this.hubUrl) + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => { + if (retryContext.previousRetryCount >= this.maxReconnectAttempts) { + return null; // Stop trying after max attempts + } + + // Implement exponential backoff + return Math.min(this.reconnectDelayMs * Math.pow(2, retryContext.previousRetryCount), 30000); + } + }) + .build(); + + this.registerSignalREvents(); + + return this.hubConnection.start() + .then(() => { + console.log('SignalR connection started'); + this.connectionStatusSubject.next(true); + this.reconnectAttempts = 0; + this.requestRecentLogs(); + }) + .catch(err => { + console.error('Error while starting SignalR connection:', err); + this.connectionStatusSubject.next(false); + + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + setTimeout(() => this.startConnection(), this.reconnectDelayMs); + } + + throw err; + }); + } + + /** + * Stop the SignalR connection + */ + public stopConnection(): Promise { + if (!this.hubConnection) { + return Promise.resolve(); + } + + return this.hubConnection.stop() + .then(() => { + console.log('SignalR connection stopped'); + this.connectionStatusSubject.next(false); + }) + .catch(err => { + console.error('Error while stopping SignalR connection:', err); + throw err; + }); + } + + /** + * Register event handlers for SignalR events + */ + private registerSignalREvents(): void { + this.hubConnection.on('ReceiveLog', (logEntry: LogEntry) => { + this.addToBuffer(logEntry); + const currentLogs = this.logSubject.value; + this.logSubject.next([...currentLogs, logEntry]); + }); + + this.hubConnection.onreconnected(() => { + console.log('SignalR connection reconnected'); + this.connectionStatusSubject.next(true); + this.reconnectAttempts = 0; + + // Request recent logs to ensure we have the latest data + this.requestRecentLogs(); + }); + + this.hubConnection.onreconnecting(() => { + console.log('SignalR connection reconnecting...'); + this.connectionStatusSubject.next(false); + }); + + this.hubConnection.onclose(() => { + console.log('SignalR connection closed'); + this.connectionStatusSubject.next(false); + }); + } + + /** + * 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)); + } + } + + /** + * Add a log entry to the buffer + */ + private addToBuffer(logEntry: LogEntry): void { + this.logBuffer.push(logEntry); + + // Trim buffer if it exceeds the limit + if (this.logBuffer.length > this.bufferSize) { + this.logBuffer.shift(); + } + } + + /** + * Get all logs from the buffer + */ + public getBufferedLogs(): LogEntry[] { + return [...this.logBuffer]; + } + + /** + * Get logs as an observable + */ + public getLogs(): Observable { + return this.logSubject.asObservable(); + } + + /** + * Get connection status as an observable + */ + public getConnectionStatus(): Observable { + return this.connectionStatusSubject.asObservable(); + } + + /** + * Clean up resources + */ + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.stopConnection(); + } } diff --git a/code/UI/src/app/logging/logs-viewer/logs-viewer.component.html b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.html new file mode 100644 index 00000000..0ff26752 --- /dev/null +++ b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.html @@ -0,0 +1,115 @@ +
+ +
+ + Connecting to server... +
+
+ + +
+
+

Application Logs

+ +
+ +
+
+ +
+
+ + +
+ + +
+
+ + + +
+ + + +
+ +
+ + + + + + +
+
+ + + + + + Timestamp + Level + Category + Message + Job Name + Instance + + + + + + {{ log.timestamp | date: 'medium' }} + + + + {{ log.category }} + {{ log.message }} + {{ log.jobName }} + {{ log.instanceName }} + + + +
+
{{ log.exception }}
+
+ + +
+ + + + +
+ +

No logs found. Waiting for new logs...

+ +
+

Not connected to log hub. Reconnecting...

+ +
+
+
+ + +
+
+
+
diff --git a/code/UI/src/app/logging/logs-viewer/logs-viewer.component.scss b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.scss new file mode 100644 index 00000000..5b0a12d9 --- /dev/null +++ b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.scss @@ -0,0 +1,137 @@ +.logs-container { + min-height: 85vh; + margin-bottom: 2rem; + background-color: var(--surface-card); + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + padding: 1.5rem; + + .filter-container { + padding-bottom: 1rem; + border-bottom: 1px solid var(--surface-border); + } + + ::ng-deep { + // Table overrides + .p-datatable { + .p-datatable-header { + background-color: var(--surface-section); + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + padding: 1rem; + } + + .p-datatable-thead > tr > th { + background-color: var(--surface-section); + color: var(--text-color); + border-color: var(--surface-border); + padding: 0.75rem 1rem; + font-weight: 600; + } + + .p-datatable-tbody > tr { + &:nth-child(even) { + background-color: var(--surface-ground); + } + + > td { + padding: 0.75rem 1rem; + border-color: var(--surface-border); + } + } + + .p-paginator { + background-color: var(--surface-section); + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + border-color: var(--surface-border); + } + } + + // Exception handling + .exception-row { + background-color: var(--surface-hover) !important; + margin: 0; + } + + .exception-cell { + padding: 0 !important; + } + + .exception-content { + padding: 0.75rem 1rem; + background-color: var(--surface-hover); + font-family: var(--font-family-monospace, monospace); + white-space: pre-wrap; + font-size: 0.85rem; + overflow: auto; + max-height: 200px; + color: var(--text-color); + border-top: 1px dashed var(--surface-border); + } + + // Tag styling + .p-tag { + font-weight: 600; + border-radius: var(--border-radius); + + &.p-tag-danger { + background-color: var(--red-500); + color: var(--red-50); + } + + &.p-tag-warning { + background-color: var(--yellow-500); + color: var(--yellow-900); + } + + &.p-tag-info { + background-color: var(--blue-500); + color: var(--blue-50); + } + + &.p-tag-success { + background-color: var(--green-500); + color: var(--green-50); + } + } + + // Dropdowns and inputs + .p-dropdown { + min-width: 180px; + border-radius: var(--border-radius); + } + + .p-input-icon-left { + width: 100%; + max-width: 300px; + + input { + border-radius: var(--border-radius); + width: 100%; + } + } + + // Button styling + .p-button { + border-radius: var(--border-radius); + + &.p-button-outlined { + background-color: transparent; + color: var(--primary-color); + border-color: var(--primary-color); + + &:hover { + background-color: var(--primary-50); + } + } + } + + // Refresh button container + .refresh-container { + margin-bottom: 1rem; + display: flex; + justify-content: flex-end; + } + } +} \ No newline at end of file diff --git a/code/UI/src/app/logging/logs-viewer/logs-viewer.component.spec.ts b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.spec.ts new file mode 100644 index 00000000..5aa93ce8 --- /dev/null +++ b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LogsViewerComponent } from './logs-viewer.component'; + +describe('LogsViewerComponent', () => { + let component: LogsViewerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LogsViewerComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LogsViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 a0a99cdd..2b4ff01d 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 @@ -1,5 +1,5 @@ import { Component, OnInit, OnDestroy, signal, computed, inject } from '@angular/core'; -import { AsyncPipe, DatePipe, NgClass, NgFor, NgIf } from '@angular/common'; +import { DatePipe, NgIf } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Subject, takeUntil } from 'rxjs'; @@ -22,9 +22,6 @@ import { LogEntry, SignalrService } from '../../core/services/signalr.service'; standalone: true, imports: [ NgIf, - NgFor, - NgClass, - AsyncPipe, DatePipe, FormsModule, TableModule,