Add handling for items that are not being blocked (#346)

This commit is contained in:
Flaminel
2025-10-23 18:12:42 +03:00
committed by GitHub
parent 6aac35181b
commit bf826da1ae
22 changed files with 1259 additions and 23 deletions

View File

@@ -8,6 +8,15 @@ export interface AppEvent {
trackingId?: string;
}
export interface ManualEvent {
id: string;
timestamp: Date;
message: string;
data?: string;
severity: string;
isResolved: boolean;
}
export interface EventStats {
totalEvents: number;
eventsBySeverity: { severity: string; count: number }[];

View File

@@ -2,7 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import * as signalR from '@microsoft/signalr';
import { LogEntry } from '../models/signalr.models';
import { AppEvent } from '../models/event.models';
import { AppEvent, ManualEvent } from '../models/event.models';
import { AppStatus } from '../models/app-status.model';
import { JobInfo } from '../models/job.models';
import { ApplicationPathService } from './base-path.service';
@@ -18,12 +18,14 @@ export class AppHubService {
private connectionStatusSubject = new BehaviorSubject<boolean>(false);
private logsSubject = new BehaviorSubject<LogEntry[]>([]);
private eventsSubject = new BehaviorSubject<AppEvent[]>([]);
private manualEventsSubject = new BehaviorSubject<ManualEvent[]>([]);
private appStatusSubject = new BehaviorSubject<AppStatus | null>(null);
private jobsSubject = new BehaviorSubject<JobInfo[]>([]);
private readonly ApplicationPathService = inject(ApplicationPathService);
private logBuffer: LogEntry[] = [];
private eventBuffer: AppEvent[] = [];
private manualEventBuffer: ManualEvent[] = [];
private readonly bufferSize = 1000;
constructor() { }
@@ -117,6 +119,24 @@ export class AppHubService {
}
});
// Handle individual manual event messages
this.hubConnection.on('ManualEventReceived', (event: ManualEvent) => {
this.addManualEventToBuffer(event);
const currentEvents = this.manualEventsSubject.value;
this.manualEventsSubject.next([...currentEvents, event]);
});
// Handle bulk manual event messages (initial load)
this.hubConnection.on('ManualEventsReceived', (events: ManualEvent[]) => {
if (events && events.length > 0) {
// Set all manual events at once
this.manualEventsSubject.next(events);
// Update buffer
this.manualEventBuffer = [...events];
this.trimBuffer(this.manualEventBuffer, this.bufferSize);
}
});
this.hubConnection.on('AppStatusUpdated', (status: AppStatus | null) => {
if (!status) {
this.appStatusSubject.next(null);
@@ -158,6 +178,7 @@ export class AppHubService {
private requestInitialData(): void {
this.requestRecentLogs();
this.requestRecentEvents();
this.requestRecentManualEvents();
this.requestJobStatus();
}
@@ -180,12 +201,22 @@ export class AppHubService {
.catch(err => console.error('Error requesting recent events:', err));
}
}
/**
* Request recent manual events from the server
*/
public requestRecentManualEvents(count: number = 100): void {
if (this.isConnected()) {
this.hubConnection.invoke('GetRecentManualEvents', count)
.catch(err => console.error('Error requesting recent manual events:', err));
}
}
/**
* Check if the connection is established
*/
private isConnected(): boolean {
return this.hubConnection &&
return this.hubConnection &&
this.hubConnection.state === signalR.HubConnectionState.Connected;
}
@@ -222,7 +253,15 @@ export class AppHubService {
this.eventBuffer.push(event);
this.trimBuffer(this.eventBuffer, this.bufferSize);
}
/**
* Add a manual event to the buffer
*/
private addManualEventToBuffer(event: ManualEvent): void {
this.manualEventBuffer.push(event);
this.trimBuffer(this.manualEventBuffer, this.bufferSize);
}
/**
* Trim a buffer to the specified size
*/
@@ -248,6 +287,13 @@ export class AppHubService {
return this.eventsSubject.asObservable();
}
/**
* Get manual events as an observable
*/
public getManualEvents(): Observable<ManualEvent[]> {
return this.manualEventsSubject.asObservable();
}
/**
* Get jobs as an observable
*/
@@ -307,7 +353,15 @@ export class AppHubService {
this.eventsSubject.next([]);
this.eventBuffer = [];
}
/**
* Clear manual events
*/
public clearManualEvents(): void {
this.manualEventsSubject.next([]);
this.manualEventBuffer = [];
}
/**
* Clear logs
*/
@@ -315,4 +369,13 @@ export class AppHubService {
this.logsSubject.next([]);
this.logBuffer = [];
}
/**
* Remove a specific manual event from the subject
*/
public removeManualEvent(eventId: string): void {
const currentEvents = this.manualEventsSubject.value;
const filteredEvents = currentEvents.filter(e => e.id !== eventId);
this.manualEventsSubject.next(filteredEvents);
}
}

View File

@@ -0,0 +1,109 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ManualEvent } from '../models/event.models';
import { ApplicationPathService } from './base-path.service';
export interface PaginatedResult<T> {
items: T[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
export interface ManualEventFilter {
page?: number;
pageSize?: number;
isResolved?: boolean;
severity?: string;
fromDate?: Date;
toDate?: Date;
search?: string;
}
export interface ManualEventStats {
totalEvents: number;
unresolvedEvents: number;
resolvedEvents: number;
eventsBySeverity: { severity: string; count: number }[];
unresolvedBySeverity: { severity: string; count: number }[];
}
@Injectable({
providedIn: 'root'
})
export class ManualEventsService {
private readonly http = inject(HttpClient);
private readonly applicationPathService = inject(ApplicationPathService);
private readonly baseUrl = this.applicationPathService.buildApiUrl('/manualevents');
/**
* Get manual events with pagination and filtering
*/
getManualEvents(filter?: ManualEventFilter): Observable<PaginatedResult<ManualEvent>> {
let params = new HttpParams();
if (filter) {
if (filter.page !== undefined) {
params = params.set('page', filter.page.toString());
}
if (filter.pageSize !== undefined) {
params = params.set('pageSize', filter.pageSize.toString());
}
if (filter.isResolved !== undefined) {
params = params.set('isResolved', filter.isResolved.toString());
}
if (filter.severity) {
params = params.set('severity', filter.severity);
}
if (filter.fromDate) {
params = params.set('fromDate', filter.fromDate.toISOString());
}
if (filter.toDate) {
params = params.set('toDate', filter.toDate.toISOString());
}
if (filter.search) {
params = params.set('search', filter.search);
}
}
return this.http.get<PaginatedResult<ManualEvent>>(this.baseUrl, { params });
}
/**
* Get a specific manual event by ID
*/
getManualEvent(id: string): Observable<ManualEvent> {
return this.http.get<ManualEvent>(`${this.baseUrl}/${id}`);
}
/**
* Mark a manual event as resolved
*/
resolveManualEvent(id: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/${id}/resolve`, {});
}
/**
* Get manual event statistics
*/
getManualEventStats(): Observable<ManualEventStats> {
return this.http.get<ManualEventStats>(`${this.baseUrl}/stats`);
}
/**
* Get available severities
*/
getSeverities(): Observable<string[]> {
return this.http.get<string[]>(`${this.baseUrl}/severities`);
}
/**
* Trigger cleanup of old resolved events
*/
cleanupOldResolvedEvents(retentionDays: number = 30): Observable<{ deletedCount: number }> {
const params = new HttpParams().set('retentionDays', retentionDays.toString());
return this.http.post<{ deletedCount: number }>(`${this.baseUrl}/cleanup`, {}, { params });
}
}

View File

@@ -7,7 +7,104 @@
<div class="mb-4" *ngIf="showSupportSection()">
<app-support-section></app-support-section>
</div>
<!-- Manual Events Card (Action Required) -->
<div class="mb-4" *ngIf="currentManualEvent()">
<div class="glow-event-card-wrapper" [ngClass]="getManualEventSeverityClass(currentManualEvent()!.severity)">
<p-card styleClass="dashboard-card">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<div class="flex align-items-center gap-2">
<i class="pi pi-exclamation-triangle text-2xl"></i>
<h2 class="card-title m-0">Action Required</h2>
</div>
<span class="card-subtitle">Manual intervention needed</span>
</div>
<div class="flex align-items-center gap-2">
<p-tag
[severity]="getEventSeverity(currentManualEvent()!.severity)"
[value]="currentManualEvent()!.severity"
></p-tag>
</div>
</div>
</ng-template>
<div class="card-content">
<!-- Event Message -->
<div class="event-message mb-3">
<h3 class="text-xl font-semibold mb-2" [innerHTML]="processManualEventMessage(currentManualEvent()!.message)"></h3>
<p class="text-sm text-color-secondary">
{{ currentManualEvent()!.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }}
</p>
</div>
<!-- Event Data (Collapsible) -->
<div class="event-data mb-3" *ngIf="currentManualEvent()!.data">
<p-accordion [multiple]="false">
<p-accordion-panel [value]="0">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Event Details
</p-accordion-header>
<p-accordion-content>
<div class="event-data-content">
<pre class="text-sm">{{ parseEventData(currentManualEvent()!.data) | json }}</pre>
</div>
</p-accordion-content>
</p-accordion-panel>
</p-accordion>
</div>
<!-- Navigation and Action Controls -->
<div class="glow-card-footer">
<div class="flex align-items-center justify-content-between gap-3">
<!-- Navigation -->
<div class="flex align-items-center gap-2">
<button
pButton
icon="pi pi-chevron-left"
class="p-button-outlined p-button-sm"
(click)="previousManualEvent()"
[disabled]="!canNavigatePrevious()"
pTooltip="Previous event"
tooltipPosition="top"
></button>
<span class="text-sm font-medium">
{{ currentManualEventIndex() + 1 }} of {{ unresolvedManualEvents().length }}
</span>
<button
pButton
icon="pi pi-chevron-right"
class="p-button-outlined p-button-sm"
(click)="nextManualEvent()"
[disabled]="!canNavigateNext()"
pTooltip="Next event"
tooltipPosition="top"
></button>
</div>
<!-- Dismiss Button -->
<button
pButton
label="Dismiss"
icon="pi pi-check"
class="p-button-success"
(click)="dismissManualEvent(currentManualEvent()!.id)"
></button>
</div>
</div>
</div>
</p-card>
</div>
</div>
<!-- Real-time Cards -->
<div class="grid">
<!-- Recent Logs Card -->

View File

@@ -1,3 +1,224 @@
// Glowing event card wrapper styles
.glow-event-card-wrapper {
position: relative;
border-radius: 10px;
animation: slideInUp 0.6s ease-out forwards, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s;
::ng-deep .dashboard-card.p-card {
border: 3px solid !important;
border-radius: 10px !important;
box-shadow: none !important;
}
// Override hover effect from dashboard-card
::ng-deep .dashboard-card.p-card:hover {
transform: none !important;
box-shadow: none !important;
}
&.severity-error {
box-shadow:
0 0 20px rgba(239, 68, 68, 0.7),
0 0 40px rgba(239, 68, 68, 0.5),
0 0 60px rgba(239, 68, 68, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderError 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--red-500) !important;
}
}
&.severity-warning {
box-shadow:
0 0 20px rgba(234, 179, 8, 0.7),
0 0 40px rgba(234, 179, 8, 0.5),
0 0 60px rgba(234, 179, 8, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderWarning 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--yellow-500) !important;
}
}
&.severity-important {
box-shadow:
0 0 20px rgba(249, 115, 22, 0.7),
0 0 40px rgba(249, 115, 22, 0.5),
0 0 60px rgba(249, 115, 22, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderImportant 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--orange-500) !important;
}
}
&.severity-info {
box-shadow:
0 0 20px rgba(59, 130, 246, 0.7),
0 0 40px rgba(59, 130, 246, 0.5),
0 0 60px rgba(59, 130, 246, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderInfo 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--blue-500) !important;
}
}
&.severity-default {
box-shadow:
0 0 20px rgba(100, 100, 100, 0.5),
0 0 40px rgba(100, 100, 100, 0.3),
0 0 60px rgba(100, 100, 100, 0.2);
animation: slideInUp 0.6s ease-out forwards, glowBorderDefault 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--surface-border) !important;
}
}
.event-data-content {
max-height: 300px;
overflow-y: auto;
background: var(--surface-50);
border-radius: 6px;
padding: 1rem;
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Courier New', monospace;
}
}
.glow-card-footer {
border-top: 1px solid var(--surface-border);
padding-top: 1rem;
margin-top: 1rem;
@media (max-width: 768px) {
.flex.align-items-center.justify-content-between {
flex-direction: column;
align-items: stretch;
gap: 1rem;
.flex.align-items-center.gap-2 {
justify-content: center;
}
button {
width: 100%;
}
}
}
}
}
// Glowing border animations
@keyframes glowBorderError {
0%, 100% {
box-shadow:
0 0 20px rgba(239, 68, 68, 0.7),
0 0 40px rgba(239, 68, 68, 0.5),
0 0 60px rgba(239, 68, 68, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(239, 68, 68, 0.9),
0 0 60px rgba(239, 68, 68, 0.7),
0 0 90px rgba(239, 68, 68, 0.5);
}
}
@keyframes glowBorderImportant {
0%, 100% {
box-shadow:
0 0 20px rgba(249, 115, 22, 0.7),
0 0 40px rgba(249, 115, 22, 0.5),
0 0 60px rgba(249, 115, 22, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(249, 115, 22, 0.9),
0 0 60px rgba(249, 115, 22, 0.7),
0 0 90px rgba(249, 115, 22, 0.5);
}
}
@keyframes glowBorderWarning {
0%, 100% {
box-shadow:
0 0 20px rgba(234, 179, 8, 0.7),
0 0 40px rgba(234, 179, 8, 0.5),
0 0 60px rgba(234, 179, 8, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(234, 179, 8, 0.9),
0 0 60px rgba(234, 179, 8, 0.7),
0 0 90px rgba(234, 179, 8, 0.5);
}
}
@keyframes glowBorderInfo {
0%, 100% {
box-shadow:
0 0 20px rgba(59, 130, 246, 0.7),
0 0 40px rgba(59, 130, 246, 0.5),
0 0 60px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(59, 130, 246, 0.9),
0 0 60px rgba(59, 130, 246, 0.7),
0 0 90px rgba(59, 130, 246, 0.5);
}
}
@keyframes glowBorderDefault {
0%, 100% {
box-shadow:
0 0 20px rgba(100, 100, 100, 0.5),
0 0 40px rgba(100, 100, 100, 0.3),
0 0 60px rgba(100, 100, 100, 0.2);
}
50% {
box-shadow:
0 0 35px rgba(100, 100, 100, 0.7),
0 0 60px rgba(100, 100, 100, 0.5),
0 0 90px rgba(100, 100, 100, 0.3);
}
}
// Shake animation
@keyframes shake {
0%,
100% {
transform: translateX(0) rotate(0deg);
}
2% {
transform: translateX(-3px) rotate(-0.5deg);
}
4% {
transform: translateX(3px) rotate(0.5deg);
}
6% {
transform: translateX(-3px) rotate(-0.5deg);
}
8% {
transform: translateX(3px) rotate(0.5deg);
}
10% {
transform: translateX(0) rotate(0deg);
}
}
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
@@ -571,4 +792,16 @@
display: none;
}
}
}
// Slide in up animation for manual event card
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -9,12 +9,14 @@ import { ButtonModule } from 'primeng/button';
import { TagModule } from 'primeng/tag';
import { TooltipModule } from 'primeng/tooltip';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { AccordionModule } from 'primeng/accordion';
// Services & Models
import { AppHubService } from '../../core/services/app-hub.service';
import { ConfigurationService } from '../../core/services/configuration.service';
import { ManualEventsService } from '../../core/services/manual-events.service';
import { LogEntry } from '../../core/models/signalr.models';
import { AppEvent } from '../../core/models/event.models';
import { AppEvent, ManualEvent } from '../../core/models/event.models';
import { GeneralConfig } from '../../shared/models/general-config.model';
// Components
@@ -34,6 +36,7 @@ import { JobsManagementComponent } from '../../shared/components/jobs-management
TagModule,
TooltipModule,
ProgressSpinnerModule,
AccordionModule,
SupportSectionComponent,
JobsManagementComponent
],
@@ -43,11 +46,14 @@ import { JobsManagementComponent } from '../../shared/components/jobs-management
export class DashboardPageComponent implements OnInit, OnDestroy {
private appHubService = inject(AppHubService);
private configurationService = inject(ConfigurationService);
private manualEventsService = inject(ManualEventsService);
private destroy$ = new Subject<void>();
// Signals for reactive state
recentLogs = signal<LogEntry[]>([]);
recentEvents = signal<AppEvent[]>([]);
manualEvents = signal<ManualEvent[]>([]);
currentManualEventIndex = signal<number>(0);
connected = signal<boolean>(false);
generalConfig = signal<GeneralConfig | null>(null);
@@ -57,13 +63,38 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort chronologically (oldest first)
.slice(-5); // Take the last 10 (most recent);
});
displayEvents = computed(() => {
return this.recentEvents()
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort chronologically (oldest first)
.slice(-5); // Take the last 10 (most recent)
});
// Filter only unresolved manual events, sorted oldest first
unresolvedManualEvents = computed(() => {
return this.manualEvents()
.filter(e => !e.isResolved)
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
});
// Get the current manual event to display
currentManualEvent = computed(() => {
const events = this.unresolvedManualEvents();
const index = this.currentManualEventIndex();
return events.length > 0 && index < events.length ? events[index] : null;
});
// Check if we can navigate to previous event
canNavigatePrevious = computed(() => {
return this.currentManualEventIndex() > 0;
});
// Check if we can navigate to next event
canNavigateNext = computed(() => {
const events = this.unresolvedManualEvents();
return this.currentManualEventIndex() < events.length - 1;
});
// Computed value for showing support section
showSupportSection = computed(() => {
return this.generalConfig()?.displaySupportBanner ?? false;
@@ -112,6 +143,13 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
this.recentEvents.set(events);
});
// Subscribe to manual events
this.appHubService.getManualEvents()
.pipe(takeUntil(this.destroy$))
.subscribe((events: ManualEvent[]) => {
this.manualEvents.set(events);
});
// Subscribe to connection status
this.appHubService.getConnectionStatus()
.pipe(takeUntil(this.destroy$))
@@ -272,4 +310,94 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
// Convert PascalCase to readable format
return eventType.replace(/([A-Z])/g, ' $1').trim();
}
// Manual event navigation methods
nextManualEvent(): void {
if (this.canNavigateNext()) {
this.currentManualEventIndex.update(i => i + 1);
}
}
previousManualEvent(): void {
if (this.canNavigatePrevious()) {
this.currentManualEventIndex.update(i => i - 1);
}
}
dismissManualEvent(eventId: string): void {
this.manualEventsService.resolveManualEvent(eventId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
// Remove from local state immediately
this.appHubService.removeManualEvent(eventId);
// Adjust index if needed
const events = this.unresolvedManualEvents();
const currentIndex = this.currentManualEventIndex();
if (currentIndex >= events.length && currentIndex > 0) {
// If we dismissed the last event, go to the previous one
this.currentManualEventIndex.set(events.length - 1);
} else if (events.length === 0) {
// Reset to 0 if no more events
this.currentManualEventIndex.set(0);
}
// Otherwise, stay at the same index (which now shows the next event)
},
error: (error) => {
console.error('Failed to dismiss manual event:', error);
}
});
}
// Helper to parse JSON data safely
parseEventData(data: string | undefined): any {
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
// Process message to convert URLs to clickable links and handle newlines
processManualEventMessage(message: string): string {
if (!message) return '';
// First, escape HTML to prevent XSS
const escaped = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// Convert newlines to <br> tags
let processed = escaped.replace(/\\n/g, '<br>').replace(/\n/g, '<br>');
// Convert URLs to clickable links
const urlRegex = /(https?:\/\/[^\s<]+)/g;
processed = processed.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="manual-event-link">$1</a>');
return processed;
}
// Get severity class for manual events
getManualEventSeverityClass(severity: string): string {
const normalizedSeverity = severity?.toLowerCase() || '';
switch (normalizedSeverity) {
case 'error':
return 'severity-error';
case 'warning':
return 'severity-warning';
case 'information':
return 'severity-info';
case 'important':
return 'severity-important';
default:
return 'severity-default';
}
}
}