mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-04 14:27:52 -05:00
events api #1
This commit is contained in:
193
code/UI/src/app/core/services/events.service.ts
Normal file
193
code/UI/src/app/core/services/events.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user