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