events api #1

This commit is contained in:
Flaminel
2025-05-28 22:39:15 +03:00
parent 599f8959a9
commit 2b83e1a334
4 changed files with 683 additions and 265 deletions

View File

@@ -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
}
/// <summary>
/// Gets recent events
/// Gets events with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<List<AppEvent>>> GetEvents(
[FromQuery] int count = 100,
public async Task<ActionResult<PaginatedResult<AppEvent>>> 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>(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<AppEvent>
{
Items = events,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(result);
}
/// <summary>
@@ -140,4 +178,87 @@ public class EventsController : ControllerBase
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
}
/// <summary>
/// Gets the latest events for real-time updates
/// </summary>
[HttpGet("latest")]
public async Task<ActionResult<List<AppEvent>>> 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<EventSeverity>(severity, true, out var severityEnum))
query = query.Where(e => e.Severity == severityEnum);
}
if (!string.IsNullOrWhiteSpace(eventType))
{
if (Enum.TryParse<EventType>(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);
}
}
/// <summary>
/// Represents a paginated result set
/// </summary>
/// <typeparam name="T">Type of items in the result</typeparam>
public class PaginatedResult<T>
{
/// <summary>
/// The items in the current page
/// </summary>
public List<T> Items { get; set; } = new();
/// <summary>
/// Current page number (1-based)
/// </summary>
public int Page { get; set; }
/// <summary>
/// Number of items per page
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// Total number of items across all pages
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Total number of pages
/// </summary>
public int TotalPages { get; set; }
/// <summary>
/// Whether there is a previous page
/// </summary>
[JsonIgnore]
public bool HasPrevious => Page > 1;
/// <summary>
/// Whether there is a next page
/// </summary>
[JsonIgnore]
public bool HasNext => Page < TotalPages;
}

View File

@@ -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<T> {
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<PaginatedResult<AppEvent> | 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<PaginatedResult<AppEvent>> {
// 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<PaginatedResult<AppEvent>>(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<EventsFilter> = {}): Observable<AppEvent[]> {
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<AppEvent[]>(`${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<EventsFilter> = {}): 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<EventsFilter>): 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<string[]> {
return this.http.get<string[]>(`${this.apiUrl}/types`);
}
/**
* Get severities
*/
getSeverities(): Observable<string[]> {
return this.http.get<string[]>(`${this.apiUrl}/severities`);
}
}

View File

@@ -83,6 +83,33 @@
[disabled]="!isConnected()"
>
</p-select>
<!-- Date Range Filters -->
<p-datePicker
[ngModel]="fromDate()"
(ngModelChange)="fromDate.set($event); onDateFilterChange()"
dateFormat="yy-mm-dd"
[showTime]="true"
[timeOnly]="false"
placeholder="Start date"
[showClear]="true"
[showIcon]="true"
[disabled]="!isConnected()"
styleClass="date-filter"
></p-datePicker>
<p-datePicker
[ngModel]="toDate()"
(ngModelChange)="toDate.set($event); onDateFilterChange()"
dateFormat="yy-mm-dd"
[showTime]="true"
[timeOnly]="false"
placeholder="End date"
[showClear]="true"
[showIcon]="true"
[disabled]="!isConnected()"
styleClass="date-filter"
></p-datePicker>
</div>
</ng-template>
<ng-template pTemplate="end">
@@ -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())"
></button>
</div>
</div>
@@ -205,12 +232,21 @@
<div class="empty-events">
<div class="empty-message">
<i class="pi pi-inbox empty-icon"></i>
<div class="empty-text" *ngIf="isConnected(); else disconnectedMessage">No events found</div>
<p *ngIf="isConnected()">Waiting for new events or try adjusting your filters</p>
<div class="empty-text" *ngIf="isConnected(); else disconnectedMessage">
<span *ngIf="!loading()">No events found</span>
<span *ngIf="loading()">Loading events...</span>
</div>
<p *ngIf="isConnected() && !loading()">Try adjusting your filters</p>
<p-progressSpinner *ngIf="loading()"
styleClass="w-3rem h-3rem"
strokeWidth="4"
fill="var(--surface-ground)"
animationDuration=".5s"
></p-progressSpinner>
<ng-template #disconnectedMessage>
<div class="flex flex-column align-items-center gap-3">
<div class="empty-text">Not connected to event hub</div>
<p>Attempting to reconnect to the server...</p>
<div class="empty-text">Not connected to server</div>
<p>Check your network connection and try again</p>
<p-progressSpinner
styleClass="w-3rem h-3rem"
strokeWidth="4"
@@ -224,6 +260,20 @@
</ng-template>
</div>
<!-- Pagination Controls -->
<div class="pagination-container p-3 border-top-1 surface-border">
<p-paginator
[rows]="pageSize()"
[totalRecords]="totalRecords()"
[rowsPerPageOptions]="[25, 50, 100, 250]"
(onPageChange)="onPageChange($event)"
[showCurrentPageReport]="true"
currentPageReportTemplate="{first} to {last} of {totalRecords} events"
[alwaysShow]="true"
styleClass="events-paginator"
></p-paginator>
</div>
<!-- Export Menu -->
<p-menu #exportMenu [popup]="true" [model]="exportMenuItems"></p-menu>
</div>
</div>

View File

@@ -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<void>();
private clipboard = inject(Clipboard);
private search$ = new Subject<string>();
private pollingSubscription?: Subscription;
@ViewChild('eventsConsole') eventsConsole!: ElementRef;
@ViewChild('exportMenu') exportMenu: any;
// Signals for reactive state
events = signal<AppEvent[]>([]);
isConnected = signal<boolean>(false);
isConnected = signal<boolean>(true); // Always connected with HTTP
autoScroll = signal<boolean>(true);
expandedEvents: { [key: number]: boolean } = {};
loading = signal<boolean>(false);
// Pagination
currentPage = signal<number>(1);
pageSize = signal<number>(50);
totalRecords = signal<number>(0);
totalPages = signal<number>(0);
// Filter state
severityFilter = signal<string | null>(null);
eventTypeFilter = signal<string | null>(null);
searchFilter = signal<string>('');
fromDate = signal<Date | null>(null);
toDate = signal<Date | null>(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<any[]>([]);
eventTypes = signal<any[]>([]);
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<AppEvent>) => {
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;
}
}
}
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);
}
}