diff --git a/code/Executable/Controllers/EventsController.cs b/code/Executable/Controllers/EventsController.cs index 75befda4..61065880 100644 --- a/code/Executable/Controllers/EventsController.cs +++ b/code/Executable/Controllers/EventsController.cs @@ -4,6 +4,7 @@ using Data.Enums; using Infrastructure.Events; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Text.Json.Serialization; namespace Executable.Controllers; @@ -19,14 +20,22 @@ public class EventsController : ControllerBase } /// - /// Gets recent events + /// Gets events with pagination and filtering /// [HttpGet] - public async Task>> GetEvents( - [FromQuery] int count = 100, + public async Task>> GetEvents( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 100, [FromQuery] string? severity = null, - [FromQuery] string? eventType = null) + [FromQuery] string? eventType = null, + [FromQuery] DateTime? fromDate = null, + [FromQuery] DateTime? toDate = null) { + // Validate pagination parameters + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 100; + if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance + var query = _context.Events.AsQueryable(); // Apply filters @@ -41,14 +50,43 @@ public class EventsController : ControllerBase if (Enum.TryParse(eventType, true, out var eventTypeEnum)) query = query.Where(e => e.EventType == eventTypeEnum); } + + // Apply date range filters + if (fromDate.HasValue) + { + query = query.Where(e => e.Timestamp >= fromDate.Value); + } + + if (toDate.HasValue) + { + query = query.Where(e => e.Timestamp <= toDate.Value); + } - // Order and limit + // Count total matching records for pagination + var totalCount = await query.CountAsync(); + + // Calculate pagination + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var skip = (page - 1) * pageSize; + + // Get paginated data var events = await query .OrderByDescending(e => e.Timestamp) - .Take(Math.Min(count, 1000)) // Cap at 1000 + .Skip(skip) + .Take(pageSize) .ToListAsync(); - return Ok(events); + // Return paginated result + var result = new PaginatedResult + { + Items = events, + Page = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + + return Ok(result); } /// @@ -140,4 +178,87 @@ public class EventsController : ControllerBase var severities = Enum.GetNames(typeof(EventSeverity)).ToList(); return Ok(severities); } -} \ No newline at end of file + /// + /// Gets the latest events for real-time updates + /// + [HttpGet("latest")] + public async Task>> GetLatestEvents( + [FromQuery] int count = 100, + [FromQuery] string? severity = null, + [FromQuery] string? eventType = null, + [FromQuery] DateTime? after = null) + { + var query = _context.Events.AsQueryable(); + + // Apply filters + if (!string.IsNullOrWhiteSpace(severity)) + { + if (Enum.TryParse(severity, true, out var severityEnum)) + query = query.Where(e => e.Severity == severityEnum); + } + + if (!string.IsNullOrWhiteSpace(eventType)) + { + if (Enum.TryParse(eventType, true, out var eventTypeEnum)) + query = query.Where(e => e.EventType == eventTypeEnum); + } + + // Filter for events after a specific timestamp + if (after.HasValue) + { + query = query.Where(e => e.Timestamp > after.Value); + } + + // Order and limit + var events = await query + .OrderByDescending(e => e.Timestamp) + .Take(Math.Min(count, 1000)) // Cap at 1000 + .ToListAsync(); + + return Ok(events); + } +} + +/// +/// Represents a paginated result set +/// +/// Type of items in the result +public class PaginatedResult +{ + /// + /// The items in the current page + /// + public List Items { get; set; } = new(); + + /// + /// Current page number (1-based) + /// + public int Page { get; set; } + + /// + /// Number of items per page + /// + public int PageSize { get; set; } + + /// + /// Total number of items across all pages + /// + public int TotalCount { get; set; } + + /// + /// Total number of pages + /// + public int TotalPages { get; set; } + + /// + /// Whether there is a previous page + /// + [JsonIgnore] + public bool HasPrevious => Page > 1; + + /// + /// Whether there is a next page + /// + [JsonIgnore] + public bool HasNext => Page < TotalPages; +} \ No newline at end of file diff --git a/code/UI/src/app/core/services/events.service.ts b/code/UI/src/app/core/services/events.service.ts new file mode 100644 index 00000000..12c8a861 --- /dev/null +++ b/code/UI/src/app/core/services/events.service.ts @@ -0,0 +1,193 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, BehaviorSubject, interval, Subscription, of } from 'rxjs'; +import { tap, switchMap, catchError, share } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; +import { AppEvent } from '../models/event.models'; + +export interface PaginatedResult { + items: T[]; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; +} + +export interface EventsFilter { + page?: number; + pageSize?: number; + severity?: string; + eventType?: string; + fromDate?: Date | null; + toDate?: Date | null; +} + +@Injectable({ + providedIn: 'root' +}) +export class EventsService { + private http = inject(HttpClient); + private apiUrl = `${environment.apiUrl}/api/events`; + + // State management + private events = new BehaviorSubject | null>(null); + private pollingSubscription: Subscription | null = null; + private lastEventTimestamp: Date | null = null; + private isPolling = false; + private pollInterval = 5000; // 5 seconds + + // Public observables + events$ = this.events.asObservable(); + + /** + * Load events with pagination and filtering + */ + loadEvents(filter: EventsFilter = {}): Observable> { + // Set default values if not provided + const params = new HttpParams() + .set('page', filter.page?.toString() || '1') + .set('pageSize', filter.pageSize?.toString() || '100'); + + // Add optional filters if they exist + const paramsWithFilters = this.addFiltersToParams(params, filter); + + return this.http.get>(this.apiUrl, { params: paramsWithFilters }) + .pipe( + tap(result => { + this.events.next(result); + + // Update last event timestamp if there are events + if (result.items.length > 0) { + const newestEvent = result.items.reduce((prev, current) => { + return new Date(current.timestamp) > new Date(prev.timestamp) ? current : prev; + }); + this.lastEventTimestamp = new Date(newestEvent.timestamp); + } + }), + catchError(error => { + console.error('Error loading events:', error); + return of({ + items: [], + page: filter.page || 1, + pageSize: filter.pageSize || 100, + totalCount: 0, + totalPages: 0 + }); + }) + ); + } + + /** + * Get latest events (for polling) + */ + getLatestEvents(filter: Partial = {}): Observable { + let params = new HttpParams().set('count', '100'); + + // Add timestamp filter if we have a last event timestamp + if (this.lastEventTimestamp) { + params = params.set('after', this.lastEventTimestamp.toISOString()); + } + + // Add optional filters if they exist + params = this.addFiltersToParams(params, filter); + + return this.http.get(`${this.apiUrl}/latest`, { params }) + .pipe( + tap(newEvents => { + if (newEvents.length > 0) { + // Update the last event timestamp + const newestEvent = newEvents.reduce((prev, current) => { + return new Date(current.timestamp) > new Date(prev.timestamp) ? current : prev; + }); + this.lastEventTimestamp = new Date(newestEvent.timestamp); + + // Update the events list if we already have events loaded + const currentEvents = this.events.value; + if (currentEvents) { + // Combine old and new events, respecting pagination + const combinedItems = [...newEvents, ...currentEvents.items] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, currentEvents.pageSize); + + // Update the events subject + this.events.next({ + ...currentEvents, + items: combinedItems, + totalCount: currentEvents.totalCount + newEvents.length + }); + } + } + }), + catchError(error => { + console.error('Error getting latest events:', error); + return of([]); + }) + ); + } + + /** + * Start polling for new events + */ + startPolling(filter: Partial = {}): void { + if (this.isPolling) { + this.stopPolling(); + } + + this.isPolling = true; + this.pollingSubscription = interval(this.pollInterval) + .pipe( + switchMap(() => this.getLatestEvents(filter)) + ) + .subscribe(); + } + + /** + * Stop polling for new events + */ + stopPolling(): void { + if (this.pollingSubscription) { + this.pollingSubscription.unsubscribe(); + this.pollingSubscription = null; + } + this.isPolling = false; + } + + /** + * Helper to add filters to HttpParams + */ + private addFiltersToParams(params: HttpParams, filter: Partial): HttpParams { + let updatedParams = params; + + if (filter.severity) { + updatedParams = updatedParams.set('severity', filter.severity); + } + + if (filter.eventType) { + updatedParams = updatedParams.set('eventType', filter.eventType); + } + + if (filter.fromDate) { + updatedParams = updatedParams.set('fromDate', filter.fromDate.toISOString()); + } + + if (filter.toDate) { + updatedParams = updatedParams.set('toDate', filter.toDate.toISOString()); + } + + return updatedParams; + } + + /** + * Get event types + */ + getEventTypes(): Observable { + return this.http.get(`${this.apiUrl}/types`); + } + + /** + * Get severities + */ + getSeverities(): Observable { + return this.http.get(`${this.apiUrl}/severities`); + } +} diff --git a/code/UI/src/app/events/events-viewer/events-viewer.component.html b/code/UI/src/app/events/events-viewer/events-viewer.component.html index f6cea464..fd48b46c 100644 --- a/code/UI/src/app/events/events-viewer/events-viewer.component.html +++ b/code/UI/src/app/events/events-viewer/events-viewer.component.html @@ -83,6 +83,33 @@ [disabled]="!isConnected()" > + + + + + @@ -100,7 +127,7 @@ label="Clear Filters" class="p-button-outlined ml-2 clear-filters-btn" (click)="clearFilters()" - [disabled]="!isConnected() || (!severityFilter() && !eventTypeFilter() && !searchFilter())" + [disabled]="!isConnected() || (!severityFilter() && !eventTypeFilter() && !searchFilter() && !fromDate() && !toDate())" > @@ -205,12 +232,21 @@
-
No events found
-

Waiting for new events or try adjusting your filters

+
+ No events found + Loading events... +
+

Try adjusting your filters

+
-
Not connected to event hub
-

Attempting to reconnect to the server...

+
Not connected to server
+

Check your network connection and try again

+ +
+ +
+ -
\ No newline at end of file +
\ No newline at end of file diff --git a/code/UI/src/app/events/events-viewer/events-viewer.component.ts b/code/UI/src/app/events/events-viewer/events-viewer.component.ts index b733a987..1c536946 100644 --- a/code/UI/src/app/events/events-viewer/events-viewer.component.ts +++ b/code/UI/src/app/events/events-viewer/events-viewer.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, OnDestroy, signal, computed, inject, ViewChild, ElementRef } from '@angular/core'; import { DatePipe, NgFor, NgIf, NgClass } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { Subject, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs'; +import { Subject, takeUntil, debounceTime, distinctUntilChanged, Subscription } from 'rxjs'; import { Clipboard } from '@angular/cdk/clipboard'; // PrimeNG Imports @@ -17,9 +17,12 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { InputSwitchModule } from 'primeng/inputswitch'; import { MenuModule } from 'primeng/menu'; import { MenuItem } from 'primeng/api'; +import { DatePickerModule } from 'primeng/datepicker'; +import { PaginatorModule } from 'primeng/paginator'; // Services & Models import { AppHubService } from '../../core/services/app-hub.service'; +import { EventsService, EventsFilter, PaginatedResult } from '../../core/services/events.service'; import { AppEvent } from '../../core/models/event.models'; @Component({ @@ -40,31 +43,44 @@ import { AppEvent } from '../../core/models/event.models'; TooltipModule, ProgressSpinnerModule, MenuModule, - InputSwitchModule + InputSwitchModule, + DatePickerModule, + PaginatorModule ], - providers: [AppHubService], + providers: [AppHubService, EventsService], templateUrl: './events-viewer.component.html', styleUrl: './events-viewer.component.scss' }) export class EventsViewerComponent implements OnInit, OnDestroy { - private appHubService = inject(AppHubService); + private appHubService = inject(AppHubService); // Keep for dashboard + private eventsService = inject(EventsService); private destroy$ = new Subject(); private clipboard = inject(Clipboard); private search$ = new Subject(); + private pollingSubscription?: Subscription; @ViewChild('eventsConsole') eventsConsole!: ElementRef; @ViewChild('exportMenu') exportMenu: any; - + // Signals for reactive state events = signal([]); - isConnected = signal(false); + isConnected = signal(true); // Always connected with HTTP autoScroll = signal(true); expandedEvents: { [key: number]: boolean } = {}; - + loading = signal(false); + + // Pagination + currentPage = signal(1); + pageSize = signal(50); + totalRecords = signal(0); + totalPages = signal(0); + // Filter state severityFilter = signal(null); eventTypeFilter = signal(null); searchFilter = signal(''); + fromDate = signal(null); + toDate = signal(null); // Export menu items exportMenuItems: MenuItem[] = [ @@ -75,107 +91,75 @@ export class EventsViewerComponent implements OnInit, OnDestroy { // Computed values filteredEvents = computed(() => { - let filtered = this.events(); - - if (this.severityFilter()) { - filtered = filtered.filter(event => event.severity === this.severityFilter()); - } - - if (this.eventTypeFilter()) { - filtered = filtered.filter(event => event.eventType === this.eventTypeFilter()); - } - - if (this.searchFilter()) { - const search = this.searchFilter().toLowerCase(); - filtered = filtered.filter(event => - event.message.toLowerCase().includes(search) || - event.eventType.toLowerCase().includes(search) || - (event.data && event.data.toLowerCase().includes(search)) || - (event.trackingId && event.trackingId.toLowerCase().includes(search))); - } - - return filtered; + return this.events(); + // Note: We no longer need client-side filtering as filtering is done on the server }); - - severities = computed(() => { - const uniqueSeverities = [...new Set(this.events().map(event => event.severity))]; - return uniqueSeverities.map(severity => ({ label: severity, value: severity })); - }); - - eventTypes = computed(() => { - const uniqueTypes = [...new Set(this.events().map(event => event.eventType))]; - return uniqueTypes.map(type => ({ label: type, value: type })); - }); - - constructor() {} - + + severities = signal([]); + eventTypes = signal([]); + + constructor() { } + ngOnInit(): void { - // Connect to SignalR hub - this.appHubService.startConnection() - .catch((error: Error) => console.error('Failed to connect to app hub:', error)); - - // Subscribe to events - this.appHubService.getEvents() - .pipe(takeUntil(this.destroy$)) - .subscribe((events: AppEvent[]) => { - this.events.set(events); - if (this.autoScroll()) { - this.scrollToBottom(); - } - }); - - // Subscribe to connection status - this.appHubService.getEventsConnectionStatus() - .pipe(takeUntil(this.destroy$)) - .subscribe((status: boolean) => { - this.isConnected.set(status); - }); - - // Setup search debounce (300ms) + // Setup search debounce this.search$ .pipe( + takeUntil(this.destroy$), debounceTime(300), - distinctUntilChanged(), - takeUntil(this.destroy$) + distinctUntilChanged() ) - .subscribe(searchText => { - this.searchFilter.set(searchText); + .subscribe(value => { + this.searchFilter.set(value); + this.loadEvents(); }); + + // Load event types and severities + this.loadEventTypes(); + this.loadSeverities(); + + // Initial events load + this.loadEvents(); + + // Start polling for new events + this.startPolling(); } - ngAfterViewChecked(): void { - if (this.autoScroll() && this.eventsConsole) { - this.scrollToBottom(); - } - } - ngOnDestroy(): void { + this.stopPolling(); this.destroy$.next(); this.destroy$.complete(); } - + onSeverityFilterChange(severity: string): void { this.severityFilter.set(severity); + this.currentPage.set(1); // Reset to first page when filter changes + this.loadEvents(); } - + onEventTypeFilterChange(eventType: string): void { this.eventTypeFilter.set(eventType); + this.currentPage.set(1); // Reset to first page when filter changes + this.loadEvents(); } - + onSearchChange(event: Event): void { const searchText = (event.target as HTMLInputElement).value; this.search$.next(searchText); } - + clearFilters(): void { this.severityFilter.set(null); this.eventTypeFilter.set(null); this.searchFilter.set(''); + this.fromDate.set(null); + this.toDate.set(null); + this.currentPage.set(1); + this.loadEvents(); } - + getSeverity(severity: string): string { const normalizedSeverity = severity?.toLowerCase() || ''; - + switch (normalizedSeverity) { case 'error': return 'danger'; @@ -191,176 +175,13 @@ export class EventsViewerComponent implements OnInit, OnDestroy { return 'secondary'; } } - - refresh(): void { - this.appHubService.requestRecentEvents(); - } - - hasDataInfo(): boolean { - return this.events().some(event => event.data); - } - - hasTrackingInfo(): boolean { - return this.events().some(event => event.trackingId); - } - /** - * Toggle expansion of an event entry - */ - toggleEventExpansion(index: number, domEvent?: MouseEvent): void { - if (domEvent) { - domEvent.stopPropagation(); - } - this.expandedEvents[index] = !this.expandedEvents[index]; - } - - /** - * Copy a specific event entry to clipboard - */ - copyEventEntry(event: AppEvent, domEvent: MouseEvent): void { - domEvent.stopPropagation(); - - const timestamp = new Date(event.timestamp).toISOString(); - let content = `[${timestamp}] [${event.severity}] [${event.eventType}] ${event.message}`; - - if (event.trackingId) { - content += `\nTracking ID: ${event.trackingId}`; - } - - if (event.data) { - content += `\nData: ${event.data}`; - } - - this.clipboard.copy(content); - } - - /** - * Copy all filtered events to clipboard - */ - copyEvents(): void { - const events = this.filteredEvents(); - if (events.length === 0) return; - - const content = events.map(event => { - const timestamp = new Date(event.timestamp).toISOString(); - let entry = `[${timestamp}] [${event.severity}] [${event.eventType}] ${event.message}`; - - if (event.trackingId) { - entry += `\nTracking ID: ${event.trackingId}`; - } - - if (event.data) { - entry += `\nData: ${event.data}`; - } - - return entry; - }).join('\n\n'); - - this.clipboard.copy(content); - } - - /** - * Export events menu trigger - */ - exportEvents(event?: MouseEvent): void { - if (event && this.exportMenuItems.length > 0 && this.exportMenu) { - this.exportMenu.toggle(event); - } - } - - /** - * Export events as JSON - */ - exportAsJson(): void { - const events = this.filteredEvents(); - if (events.length === 0) return; - - const content = JSON.stringify(events, null, 2); - this.downloadFile(content, 'application/json', 'events.json'); - } - - /** - * Export events as CSV - */ - exportAsCsv(): void { - const events = this.filteredEvents(); - if (events.length === 0) return; - - // CSV header - let csv = 'Timestamp,Severity,EventType,Message,Data,TrackingId\n'; - - // CSV rows - events.forEach(event => { - const timestamp = new Date(event.timestamp).toISOString(); - const severity = event.severity || ''; - const eventType = event.eventType ? `"${event.eventType.replace(/"/g, '""')}"` : ''; - const message = event.message ? `"${event.message.replace(/"/g, '""')}"` : ''; - const data = event.data ? `"${event.data.replace(/"/g, '""').replace(/\n/g, ' ')}"` : ''; - const trackingId = event.trackingId ? `"${event.trackingId.replace(/"/g, '""')}"` : ''; - - csv += `${timestamp},${severity},${eventType},${message},${data},${trackingId}\n`; - }); - - this.downloadFile(csv, 'text/csv', 'events.csv'); - } - - /** - * Export events as plain text - */ - exportAsText(): void { - const events = this.filteredEvents(); - if (events.length === 0) return; - - const content = events.map(event => { - const timestamp = new Date(event.timestamp).toISOString(); - let entry = `[${timestamp}] [${event.severity}] [${event.eventType}] ${event.message}`; - - if (event.trackingId) { - entry += `\nTracking ID: ${event.trackingId}`; - } - - if (event.data) { - entry += `\nData: ${event.data}`; - } - - return entry; - }).join('\n\n'); - - this.downloadFile(content, 'text/plain', 'events.txt'); - } - - /** - * Helper method to download a file - */ - private downloadFile(content: string, contentType: string, filename: string): void { - const blob = new Blob([content], { type: contentType }); - const url = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); // Required for Firefox - link.click(); - document.body.removeChild(link); // Clean up - - setTimeout(() => { - URL.revokeObjectURL(url); - }, 100); - } - - /** - * Scroll to the bottom of the events container - */ - private scrollToBottom(): void { - if (this.eventsConsole && this.eventsConsole.nativeElement) { - const element = this.eventsConsole.nativeElement; - element.scrollTop = element.scrollHeight; + ngAfterViewChecked(): void { + if (this.autoScroll() && this.eventsConsole) { + this.scrollToBottom(); } } - /** - * Sets the auto-scroll state - */ setAutoScroll(value: boolean): void { this.autoScroll.set(value); if (value) { @@ -368,9 +189,112 @@ export class EventsViewerComponent implements OnInit, OnDestroy { } } - /** - * Format JSON data for display - */ + private scrollToBottom(): void { + if (this.eventsConsole && this.eventsConsole.nativeElement) { + const element = this.eventsConsole.nativeElement; + element.scrollTop = element.scrollHeight; + } + } + + loadEvents(): void { + this.loading.set(true); + + const filter: EventsFilter = { + page: this.currentPage(), + pageSize: this.pageSize(), + severity: this.severityFilter() || undefined, + eventType: this.eventTypeFilter() || undefined, + fromDate: this.fromDate(), + toDate: this.toDate() + }; + + this.eventsService.loadEvents(filter) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (result: PaginatedResult) => { + this.events.set(result.items); + this.totalRecords.set(result.totalCount); + this.totalPages.set(result.totalPages); + this.loading.set(false); + + // Auto-scroll to bottom if enabled + if (this.autoScroll()) { + setTimeout(() => this.scrollToBottom(), 0); + } + }, + error: (error) => { + console.error('Error loading events:', error); + this.loading.set(false); + } + }); + } + + loadEventTypes(): void { + this.eventsService.getEventTypes() + .pipe(takeUntil(this.destroy$)) + .subscribe(types => { + this.eventTypes.set(types.map(type => ({ label: type, value: type }))); + }); + } + + loadSeverities(): void { + this.eventsService.getSeverities() + .pipe(takeUntil(this.destroy$)) + .subscribe(severities => { + this.severities.set(severities.map(severity => ({ label: severity, value: severity }))); + }); + } + + onPageChange(event: any): void { + this.currentPage.set(event.page + 1); // PrimeNG paginator is 0-based + this.pageSize.set(event.rows); + this.loadEvents(); + } + + onDateFilterChange(): void { + this.currentPage.set(1); // Reset to first page + this.loadEvents(); + } + + startPolling(): void { + // Stop any existing polling + this.stopPolling(); + + // Create filter for polling (only apply type and severity filters, not pagination) + const pollingFilter = { + severity: this.severityFilter() || undefined, + eventType: this.eventTypeFilter() || undefined, + fromDate: this.fromDate(), + toDate: this.toDate() + }; + + this.eventsService.startPolling(pollingFilter); + + // Subscribe to events stream to update UI + this.pollingSubscription = this.eventsService.events$ + .pipe(takeUntil(this.destroy$)) + .subscribe(result => { + if (result) { + this.events.set(result.items); + this.totalRecords.set(result.totalCount); + this.totalPages.set(result.totalPages); + + // Auto-scroll to bottom if enabled + if (this.autoScroll()) { + setTimeout(() => this.scrollToBottom(), 0); + } + } + }); + } + + stopPolling(): void { + this.eventsService.stopPolling(); + if (this.pollingSubscription) { + this.pollingSubscription.unsubscribe(); + this.pollingSubscription = undefined; + } + } + formatJsonData(data: string): string { try { const parsed = JSON.parse(data); @@ -380,9 +304,6 @@ export class EventsViewerComponent implements OnInit, OnDestroy { } } - /** - * Check if data is valid JSON - */ isValidJson(data: string): boolean { try { JSON.parse(data); @@ -391,4 +312,137 @@ export class EventsViewerComponent implements OnInit, OnDestroy { return false; } } -} \ No newline at end of file + + copyEventEntry(event: AppEvent, domEvent: MouseEvent): void { + domEvent.stopPropagation(); + + const timestamp = new Date(event.timestamp).toISOString(); + let content = `[${timestamp}] [${event.severity}] [${event.eventType}] ${event.message}`; + + if (event.trackingId) { + content += `\nTracking ID: ${event.trackingId}`; + } + + if (event.data) { + content += `\nData: ${event.data}`; + } + + this.clipboard.copy(content); + } + + copyEvents(): void { + const events = this.filteredEvents(); + if (events.length === 0) return; + + const content = events.map(event => { + const timestamp = new Date(event.timestamp).toISOString(); + let entry = `[${timestamp}] [${event.severity}] [${event.eventType}] ${event.message}`; + + if (event.trackingId) { + entry += `\nTracking ID: ${event.trackingId}`; + } + + if (event.data) { + entry += `\nData: ${event.data}`; + } + + return entry; + }).join('\n\n'); + + this.clipboard.copy(content); + } + + exportEvents(event?: MouseEvent): void { + if (event && this.exportMenuItems.length > 0 && this.exportMenu) { + this.exportMenu.toggle(event); + } + } + + exportAsJson(): void { + const events = this.filteredEvents(); + if (events.length === 0) return; + + const content = JSON.stringify(events, null, 2); + this.downloadFile(content, 'application/json', 'events.json'); + } + + exportAsCsv(): void { + const events = this.filteredEvents(); + if (events.length === 0) return; + + // CSV header + let csv = 'Timestamp,Severity,EventType,Message,Data,TrackingId\n'; + + // CSV rows + events.forEach(event => { + const timestamp = new Date(event.timestamp).toISOString(); + const severity = event.severity || ''; + const eventType = event.eventType ? `"${event.eventType.replace(/"/g, '""')}"` : ''; + const message = event.message ? `"${event.message.replace(/"/g, '""')}"` : ''; + const data = event.data ? `"${event.data.replace(/"/g, '""').replace(/\n/g, ' ')}"` : ''; + const trackingId = event.trackingId ? `"${event.trackingId.replace(/"/g, '""')}"` : ''; + + csv += `${timestamp},${severity},${eventType},${message},${data},${trackingId}\n`; + }); + + this.downloadFile(csv, 'text/csv', 'events.csv'); + } + + exportAsText(): void { + const events = this.filteredEvents(); + if (events.length === 0) return; + + const content = events.map(event => { + const timestamp = new Date(event.timestamp).toISOString(); + let entry = `[${timestamp}] [${event.severity}] [${event.eventType}] ${event.message}`; + + if (event.trackingId) { + entry += `\nTracking ID: ${event.trackingId}`; + } + + if (event.data) { + entry += `\nData: ${event.data}`; + } + + return entry; + }).join('\n\n'); + + this.downloadFile(content, 'text/plain', 'events.txt'); + } + + private downloadFile(content: string, contentType: string, filename: string): void { + const blob = new Blob([content], { type: contentType }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); // Required for Firefox + link.click(); + document.body.removeChild(link); // Clean up + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 100); + } + + toggleEventExpansion(index: number, domEvent?: MouseEvent): void { + if (domEvent) { + domEvent.stopPropagation(); + } + this.expandedEvents[index] = !this.expandedEvents[index]; + } + + refresh(): void { + // Reload events from the server + this.loadEvents(); + } + + hasDataInfo(): boolean { + return this.events().some(event => event.data); + } + + hasTrackingInfo(): boolean { + return this.events().some(event => event.trackingId); + } +} \ No newline at end of file