diff --git a/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.scss b/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.scss
index 69e8a46e..798e9d1d 100644
--- a/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.scss
+++ b/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.scss
@@ -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);
+ }
}
\ No newline at end of file
diff --git a/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.ts b/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.ts
index cab4a7f1..a53ad807 100644
--- a/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.ts
+++ b/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.ts
@@ -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
();
// Signals for reactive state
recentLogs = signal([]);
recentEvents = signal([]);
+ manualEvents = signal([]);
+ currentManualEventIndex = signal(0);
connected = signal(false);
generalConfig = signal(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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ // Convert newlines to
tags
+ let processed = escaped.replace(/\\n/g, '
').replace(/\n/g, '
');
+
+ // Convert URLs to clickable links
+ const urlRegex = /(https?:\/\/[^\s<]+)/g;
+ processed = processed.replace(urlRegex, '$1');
+
+ 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';
+ }
+ }
}