diff --git a/code/Executable/Executable.csproj b/code/Executable/Executable.csproj index b0ec404c..2e895ecf 100644 --- a/code/Executable/Executable.csproj +++ b/code/Executable/Executable.csproj @@ -31,4 +31,8 @@ + + + + diff --git a/code/UI/package-lock.json b/code/UI/package-lock.json index 67d2782e..90d337fa 100644 --- a/code/UI/package-lock.json +++ b/code/UI/package-lock.json @@ -17,6 +17,7 @@ "@angular/router": "^19.2.0", "@angular/service-worker": "^19.2.0", "@microsoft/signalr": "^8.0.7", + "@ngrx/signals": "^19.2.0", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^19.1.3", @@ -3771,6 +3772,23 @@ "node": ">= 10" } }, + "node_modules/@ngrx/signals": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-19.2.0.tgz", + "integrity": "sha512-7NMFZE5HU/bryU15vCA2aHDYa77Pb7nCit4UOcsk9WkCSWMwtyQ4/9eR6wSGdeX5xxDcFQy0IiZJ8wxYCxA5Eg==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^19.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/@ngtools/webpack": { "version": "19.2.12", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.12.tgz", diff --git a/code/UI/package.json b/code/UI/package.json index 2da66942..78057c4e 100644 --- a/code/UI/package.json +++ b/code/UI/package.json @@ -19,6 +19,7 @@ "@angular/router": "^19.2.0", "@angular/service-worker": "^19.2.0", "@microsoft/signalr": "^8.0.7", + "@ngrx/signals": "^19.2.0", "primeflex": "^4.0.0", "primeicons": "^7.0.0", "primeng": "^19.1.3", @@ -39,4 +40,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/logging/logs-viewer/logs-viewer.component.ts b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.ts new file mode 100644 index 00000000..a0a99cdd --- /dev/null +++ b/code/UI/src/app/logging/logs-viewer/logs-viewer.component.ts @@ -0,0 +1,166 @@ +import { Component, OnInit, OnDestroy, signal, computed, inject } from '@angular/core'; +import { AsyncPipe, DatePipe, NgClass, NgFor, NgIf } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Subject, takeUntil } from 'rxjs'; + +// PrimeNG Imports +import { TableModule } from 'primeng/table'; +import { InputTextModule } from 'primeng/inputtext'; +import { ButtonModule } from 'primeng/button'; +import { DropdownModule } from 'primeng/dropdown'; +import { TagModule } from 'primeng/tag'; +import { CardModule } from 'primeng/card'; +import { ToolbarModule } from 'primeng/toolbar'; +import { TooltipModule } from 'primeng/tooltip'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; + +// Services +import { LogEntry, SignalrService } from '../../core/services/signalr.service'; + +@Component({ + selector: 'app-logs-viewer', + standalone: true, + imports: [ + NgIf, + NgFor, + NgClass, + AsyncPipe, + DatePipe, + FormsModule, + TableModule, + InputTextModule, + ButtonModule, + DropdownModule, + TagModule, + CardModule, + ToolbarModule, + TooltipModule, + ProgressSpinnerModule + ], + providers: [SignalrService], + templateUrl: './logs-viewer.component.html', + styleUrl: './logs-viewer.component.scss' +}) +export class LogsViewerComponent implements OnInit, OnDestroy { + private signalrService = inject(SignalrService); + private destroy$ = new Subject(); + + // Signals for reactive state + logs = signal([]); + isConnected = signal(false); + + // Filter state + levelFilter = signal(null); + categoryFilter = signal(null); + searchFilter = ''; + + // Computed values + filteredLogs = computed(() => { + let filtered = this.logs(); + + if (this.levelFilter()) { + filtered = filtered.filter(log => log.level === this.levelFilter()); + } + + if (this.categoryFilter()) { + filtered = filtered.filter(log => log.category === this.categoryFilter()); + } + + if (this.searchFilter) { + const search = this.searchFilter.toLowerCase(); + filtered = filtered.filter(log => + log.message.toLowerCase().includes(search) || + (log.exception && log.exception.toLowerCase().includes(search))); + } + + return filtered; + }); + + levels = computed(() => { + const uniqueLevels = [...new Set(this.logs().map(log => log.level))]; + return uniqueLevels.map(level => ({ label: level, value: level })); + }); + + categories = computed(() => { + const uniqueCategories = [...new Set(this.logs().map(log => log.category).filter(Boolean))]; + return uniqueCategories.map(category => ({ label: category, value: category })); + }); + + constructor() {} + + ngOnInit(): void { + // Connect to SignalR hub + this.signalrService.startConnection() + .catch((error: Error) => console.error('Failed to connect to SignalR hub:', error)); + + // Subscribe to logs + this.signalrService.getLogs() + .pipe(takeUntil(this.destroy$)) + .subscribe((logs: LogEntry[]) => { + this.logs.set(logs); + }); + + // Subscribe to connection status + this.signalrService.getConnectionStatus() + .pipe(takeUntil(this.destroy$)) + .subscribe((status: boolean) => { + this.isConnected.set(status); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onLevelFilterChange(level: string | null): void { + this.levelFilter.set(level); + } + + onCategoryFilterChange(category: string | null): void { + this.categoryFilter.set(category); + } + + onSearchChange(event: Event): void { + this.searchFilter = (event.target as HTMLInputElement).value; + } + + clearFilters(): void { + this.levelFilter.set(null); + this.categoryFilter.set(null); + this.searchFilter = ''; + } + + getSeverity(level: string): string { + const normalizedLevel = level?.toLowerCase() || ''; + + switch (normalizedLevel) { + case 'error': + case 'fatal': + case 'critical': + return 'danger'; + case 'warning': + return 'warning'; + case 'information': + case 'info': + return 'info'; + case 'debug': + case 'trace': + return 'success'; + default: + return 'info'; + } + } + + refresh(): void { + this.signalrService.requestRecentLogs(); + } + + hasJobInfo(): boolean { + return this.logs().some(log => log.jobName); + } + + hasInstanceInfo(): boolean { + return this.logs().some(log => log.instanceName); + } +}