diff --git a/code/backend/Cleanuparr.Api/Controllers/ManualEventsController.cs b/code/backend/Cleanuparr.Api/Controllers/ManualEventsController.cs new file mode 100644 index 00000000..7cab6460 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/ManualEventsController.cs @@ -0,0 +1,180 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Events; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ManualEventsController : ControllerBase +{ + private readonly EventsContext _context; + + public ManualEventsController(EventsContext context) + { + _context = context; + } + + /// + /// Gets manual events with pagination and filtering + /// + [HttpGet] + public async Task>> GetManualEvents( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 100, + [FromQuery] bool? isResolved = null, + [FromQuery] string? severity = null, + [FromQuery] DateTime? fromDate = null, + [FromQuery] DateTime? toDate = null, + [FromQuery] string? search = 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.ManualEvents.AsQueryable(); + + // Apply filters + if (isResolved.HasValue) + { + query = query.Where(e => e.IsResolved == isResolved.Value); + } + + if (!string.IsNullOrWhiteSpace(severity)) + { + if (Enum.TryParse(severity, true, out var severityEnum)) + query = query.Where(e => e.Severity == severityEnum); + } + + // 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); + } + + // Apply search filter if provided + if (!string.IsNullOrWhiteSpace(search)) + { + string pattern = EventsContext.GetLikePattern(search); + query = query.Where(e => + EF.Functions.Like(e.Message, pattern) || + EF.Functions.Like(e.Data, pattern) + ); + } + + // 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) + .Skip(skip) + .Take(pageSize) + .ToListAsync(); + + // Return paginated result + var result = new PaginatedResult + { + Items = events, + Page = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + + return Ok(result); + } + + /// + /// Gets a specific manual event by ID + /// + [HttpGet("{id}")] + public async Task> GetManualEvent(Guid id) + { + var eventEntity = await _context.ManualEvents.FindAsync(id); + + if (eventEntity == null) + return NotFound(); + + return Ok(eventEntity); + } + + /// + /// Marks a manual event as resolved + /// + [HttpPost("{id}/resolve")] + public async Task ResolveManualEvent(Guid id) + { + var eventEntity = await _context.ManualEvents.FindAsync(id); + + if (eventEntity == null) + return NotFound(); + + eventEntity.IsResolved = true; + await _context.SaveChangesAsync(); + + return Ok(); + } + + /// + /// Gets manual event statistics + /// + [HttpGet("stats")] + public async Task> GetManualEventStats() + { + var stats = new + { + TotalEvents = await _context.ManualEvents.CountAsync(), + UnresolvedEvents = await _context.ManualEvents.CountAsync(e => !e.IsResolved), + ResolvedEvents = await _context.ManualEvents.CountAsync(e => e.IsResolved), + EventsBySeverity = await _context.ManualEvents + .GroupBy(e => e.Severity) + .Select(g => new { Severity = g.Key.ToString(), Count = g.Count() }) + .ToListAsync(), + UnresolvedBySeverity = await _context.ManualEvents + .Where(e => !e.IsResolved) + .GroupBy(e => e.Severity) + .Select(g => new { Severity = g.Key.ToString(), Count = g.Count() }) + .ToListAsync() + }; + + return Ok(stats); + } + + /// + /// Gets unique severities for manual events + /// + [HttpGet("severities")] + public async Task>> GetSeverities() + { + var severities = Enum.GetNames(typeof(EventSeverity)).ToList(); + return Ok(severities); + } + + /// + /// Manually triggers cleanup of old resolved events + /// + [HttpPost("cleanup")] + public async Task> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30) + { + var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays); + + var deletedCount = await _context.ManualEvents + .Where(e => e.IsResolved && e.Timestamp < cutoffDate) + .ExecuteDeleteAsync(); + + return Ok(new { DeletedCount = deletedCount }); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs index 105fbeff..c07cb482 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventCleanupService.cs @@ -65,6 +65,10 @@ public class EventCleanupService : BackgroundService await context.Events .Where(e => e.Timestamp < cutoffDate) .ExecuteDeleteAsync(); + await context.ManualEvents + .Where(e => e.Timestamp < cutoffDate) + .Where(e => e.IsResolved) + .ExecuteDeleteAsync(); } catch (Exception ex) { diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index 0966d102..2c2b279e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -1,4 +1,3 @@ -using System.Dynamic; using System.Text.Json; using System.Text.Json.Serialization; using Cleanuparr.Domain.Entities.Arr.Queue; @@ -8,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Hubs; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Events; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; @@ -44,7 +44,7 @@ public class EventPublisher /// public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null) { - var eventEntity = new AppEvent + AppEvent eventEntity = new() { EventType = eventType, Message = message, @@ -64,6 +64,27 @@ public class EventPublisher _logger.LogTrace("Published event: {eventType}", eventType); } + + public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null) + { + ManualEvent eventEntity = new() + { + Message = message, + Severity = severity, + Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() } + }) : null, + }; + + // Save to database with dry run interception + await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity); + + // Always send to SignalR clients (not affected by dry run) + await NotifyClientsAsync(eventEntity); + + _logger.LogTrace("Published manual event: {message}", message); + } /// /// Publishes a strike event with context data and notifications @@ -163,8 +184,8 @@ public class EventPublisher public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false) { // Get context data for the event - string downloadName = ContextProvider.Get("downloadName") ?? "Unknown"; - string hash = ContextProvider.Get("hash") ?? "Unknown"; + string downloadName = ContextProvider.Get("downloadName"); + string hash = ContextProvider.Get("hash"); // Publish the event await PublishAsync( @@ -177,11 +198,48 @@ public class EventPublisher await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag); } + /// + /// Publishes an event alerting that an item keeps coming back + /// + public async Task PublishRecurringItem(string hash, string itemName, int strikeCount) + { + var instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); + var instanceUrl = ContextProvider.Get(nameof(ArrInstance) + nameof(ArrInstance.Url)); + + // Publish the event + await PublishManualAsync( + "Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/", + EventSeverity.Important, + data: new { itemName, hash, strikeCount, instanceType, instanceUrl } + ); + } + + /// + /// Publishes an event alerting that search was not triggered for an item + /// + public async Task PublishSearchNotTriggered(string hash, string itemName) + { + var instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); + var instanceUrl = ContextProvider.Get(nameof(ArrInstance) + nameof(ArrInstance.Url)); + + await PublishManualAsync( + "Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed", + EventSeverity.Warning, + data: new { itemName, hash, instanceType, instanceUrl } + ); + } + private async Task SaveEventToDatabase(AppEvent eventEntity) { _context.Events.Add(eventEntity); await _context.SaveChangesAsync(); } + + private async Task SaveManualEventToDatabase(ManualEvent eventEntity) + { + _context.ManualEvents.Add(eventEntity); + await _context.SaveChangesAsync(); + } private async Task NotifyClientsAsync(AppEvent appEventEntity) { @@ -195,4 +253,17 @@ public class EventPublisher _logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id); } } + + private async Task NotifyClientsAsync(ManualEvent appEventEntity) + { + try + { + // Send to all connected clients via the unified AppHub + await _appHubContext.Clients.All.SendAsync("ManualEventReceived", appEventEntity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id); + } + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index cff71803..d4e19c1d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -7,28 +7,33 @@ using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; +using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Persistence.Models.Configuration.Arr; using Data.Models.Arr; using MassTransit; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.DownloadRemover; public sealed class QueueItemRemover : IQueueItemRemover { + private readonly ILogger _logger; private readonly IBus _messageBus; private readonly IMemoryCache _cache; private readonly ArrClientFactory _arrClientFactory; private readonly EventPublisher _eventPublisher; public QueueItemRemover( + ILogger logger, IBus messageBus, IMemoryCache cache, ArrClientFactory arrClientFactory, EventPublisher eventPublisher ) { + _logger = logger; _messageBus = messageBus; _cache = cache; _arrClientFactory = arrClientFactory; @@ -53,6 +58,15 @@ public sealed class QueueItemRemover : IQueueItemRemover // Use the new centralized EventPublisher method await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason); + // If recurring, do not search for replacement + string hash = request.Record.DownloadId.ToLowerInvariant(); + if (Striker.RecurringHashes.ContainsKey(hash)) + { + await _eventPublisher.PublishSearchNotTriggered(request.Record.DownloadId, request.Record.Title); + Striker.RecurringHashes.Remove(hash, out _); + return; + } + await _messageBus.Publish(new DownloadHuntRequest { InstanceType = request.InstanceType, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/IStriker.cs b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/IStriker.cs index 83845658..7209f532 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/IStriker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/IStriker.cs @@ -4,6 +4,14 @@ namespace Cleanuparr.Infrastructure.Features.ItemStriker; public interface IStriker { + /// + /// Strikes an item and checks if it has reached the maximum strikes limit + /// + /// The hash of the item + /// The name of the item + /// The maximum number of strikes + /// The strike type + /// True if the limit has been reached, otherwise false Task StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType); Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs index b22bdfd0..191fe843 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs @@ -1,4 +1,5 @@ -using Cleanuparr.Domain.Enums; +using System.Collections.Concurrent; +using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Shared.Helpers; @@ -14,6 +15,8 @@ public sealed class Striker : IStriker private readonly MemoryCacheEntryOptions _cacheOptions; private readonly EventPublisher _eventPublisher; + public static readonly ConcurrentDictionary RecurringHashes = []; + public Striker(ILogger logger, IMemoryCache cache, EventPublisher eventPublisher) { _logger = logger; @@ -23,6 +26,7 @@ public sealed class Striker : IStriker .SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer); } + /// public async Task StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType) { if (maxStrikes is 0) @@ -56,7 +60,9 @@ public sealed class Striker : IStriker if (strikeCount > maxStrikes) { _logger.LogWarning("Blocked item keeps coming back | {name}", itemName); - _logger.LogWarning("Be sure to enable \"Reject Blocklisted Torrent Hashes While Grabbing\" on your indexers to reject blocked items"); + + RecurringHashes.TryAdd(hash.ToLowerInvariant(), null); + await _eventPublisher.PublishRecurringItem(hash, itemName, strikeCount); } _logger.LogInformation("Removing item with max strikes | reason {reason} | {name}", strikeType.ToString(), itemName); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index 3b381244..55e5fc7e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.Notifications; @@ -122,7 +123,7 @@ public class NotificationPublisher : INotificationPublisher var instanceUrl = ContextProvider.Get(nameof(ArrInstance) + nameof(ArrInstance.Url)); var imageUrl = GetImageFromContext(record, instanceType); - return new NotificationContext + NotificationContext context = new() { EventType = eventType, Title = $"Strike received with reason: {strikeType}", @@ -138,6 +139,14 @@ public class NotificationPublisher : INotificationPublisher ["Url"] = instanceUrl.ToString(), } }; + + if (strikeType is StrikeType.Stalled or StrikeType.SlowSpeed or StrikeType.SlowTime) + { + var rule = ContextProvider.Get(); + context.Data.Add("Rule name", rule.Name); + } + + return context; } private NotificationContext BuildQueueItemDeletedContext(bool removeFromClient, DeleteReason reason) diff --git a/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs b/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs index 641a16d2..f7f16db3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs +++ b/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs @@ -67,6 +67,28 @@ public class AppHub : Hub } } + /// + /// Client requests recent manual events + /// + public async Task GetRecentManualEvents(int count = 100) + { + try + { + var manualEvents = await _context.ManualEvents + .Where(e => !e.IsResolved) + .OrderBy(e => e.Timestamp) // Oldest first + .Take(Math.Min(count, 100)) // Cap at 100 + .ToListAsync(); + + await Clients.Caller.SendAsync("ManualEventsReceived", manualEvents); + _logger.LogDebug("Sent {count} recent manual events to client {connectionId}", manualEvents.Count, Context.ConnectionId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send recent manual events to client"); + } + } + /// /// Client requests current job statuses /// diff --git a/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs index c17ae15d..1a2a1066 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/RuleEvaluator.cs @@ -1,6 +1,7 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.Cache; using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Helpers; @@ -48,6 +49,7 @@ public class RuleEvaluator : IRuleEvaluator } _logger.LogTrace("Applying stall rule {rule} | {name}", rule.Name, torrent.Name); + ContextProvider.Set(rule); await ResetStalledStrikesAsync( torrent, @@ -85,6 +87,7 @@ public class RuleEvaluator : IRuleEvaluator } _logger.LogTrace("Applying slow rule {rule} | {name}", rule.Name, torrent.Name); + ContextProvider.Set(rule); // Check if slow speed if (!string.IsNullOrWhiteSpace(rule.MinSpeed)) diff --git a/code/backend/Cleanuparr.Persistence/EventsContext.cs b/code/backend/Cleanuparr.Persistence/EventsContext.cs index 7abee155..892efb3a 100644 --- a/code/backend/Cleanuparr.Persistence/EventsContext.cs +++ b/code/backend/Cleanuparr.Persistence/EventsContext.cs @@ -13,6 +13,8 @@ public class EventsContext : DbContext { public DbSet Events { get; set; } + public DbSet ManualEvents { get; set; } + public EventsContext() { } diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20251023105637_AddManualEvents.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20251023105637_AddManualEvents.Designer.cs new file mode 100644 index 00000000..d984016a --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20251023105637_AddManualEvents.Designer.cs @@ -0,0 +1,128 @@ +// +using System; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + [DbContext(typeof(EventsContext))] + [Migration("20251023105637_AddManualEvents")] + partial class AddManualEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Data") + .HasColumnType("TEXT") + .HasColumnName("data"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("Timestamp") + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.Property("TrackingId") + .HasColumnType("TEXT") + .HasColumnName("tracking_id"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_type"); + + b.HasIndex("Message") + .HasDatabaseName("ix_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_events_severity"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_events_timestamp"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Data") + .HasColumnType("TEXT") + .HasColumnName("data"); + + b.Property("IsResolved") + .HasColumnType("INTEGER") + .HasColumnName("is_resolved"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("Timestamp") + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("pk_manual_events"); + + b.HasIndex("IsResolved") + .HasDatabaseName("ix_manual_events_is_resolved"); + + b.HasIndex("Message") + .HasDatabaseName("ix_manual_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_manual_events_severity"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_manual_events_timestamp"); + + b.ToTable("manual_events", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20251023105637_AddManualEvents.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20251023105637_AddManualEvents.cs new file mode 100644 index 00000000..8c7ddcc3 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20251023105637_AddManualEvents.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + /// + public partial class AddManualEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "manual_events", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + timestamp = table.Column(type: "TEXT", nullable: false), + message = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + data = table.Column(type: "TEXT", nullable: true), + severity = table.Column(type: "TEXT", nullable: false), + is_resolved = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_manual_events", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_manual_events_is_resolved", + table: "manual_events", + column: "is_resolved"); + + migrationBuilder.CreateIndex( + name: "ix_manual_events_message", + table: "manual_events", + column: "message"); + + migrationBuilder.CreateIndex( + name: "ix_manual_events_severity", + table: "manual_events", + column: "severity"); + + migrationBuilder.CreateIndex( + name: "ix_manual_events_timestamp", + table: "manual_events", + column: "timestamp", + descending: [true]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "manual_events"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs index 8d12fea2..333084a2 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs @@ -1,6 +1,6 @@ // using System; -using Data; +using Cleanuparr.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -15,9 +15,9 @@ namespace Cleanuparr.Persistence.Migrations.Events protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); - modelBuilder.Entity("Data.Models.Events.AppEvent", b => + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -70,6 +70,55 @@ namespace Cleanuparr.Persistence.Migrations.Events b.ToTable("events", (string)null); }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Data") + .HasColumnType("TEXT") + .HasColumnName("data"); + + b.Property("IsResolved") + .HasColumnType("INTEGER") + .HasColumnName("is_resolved"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("Timestamp") + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("pk_manual_events"); + + b.HasIndex("IsResolved") + .HasDatabaseName("ix_manual_events_is_resolved"); + + b.HasIndex("Message") + .HasDatabaseName("ix_manual_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_manual_events_severity"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_manual_events_timestamp"); + + b.ToTable("manual_events", (string)null); + }); #pragma warning restore 612, 618 } } diff --git a/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs b/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs index 2a1474d5..3b61e02f 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs @@ -11,7 +11,7 @@ namespace Cleanuparr.Persistence.Models.Events; [Index(nameof(EventType))] [Index(nameof(Severity))] [Index(nameof(Message))] -public class AppEvent +public class AppEvent : IEvent { [Key] public Guid Id { get; set; } = Guid.CreateVersion7(); @@ -25,9 +25,7 @@ public class AppEvent [MaxLength(1000)] public string Message { get; set; } = string.Empty; - /// - /// JSON data associated with the event - /// + /// public string? Data { get; set; } [Required] diff --git a/code/backend/Cleanuparr.Persistence/Models/Events/IEvent.cs b/code/backend/Cleanuparr.Persistence/Models/Events/IEvent.cs new file mode 100644 index 00000000..a45694c0 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Events/IEvent.cs @@ -0,0 +1,13 @@ +namespace Cleanuparr.Persistence.Models.Events; + +public interface IEvent +{ + Guid Id { get; set; } + + DateTime Timestamp { get; set; } + + /// + /// JSON data associated with the event + /// + string? Data { get; set; } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Models/Events/ManualEvent.cs b/code/backend/Cleanuparr.Persistence/Models/Events/ManualEvent.cs new file mode 100644 index 00000000..2b8b53a7 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Events/ManualEvent.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using Cleanuparr.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Persistence.Models.Events; + +/// +/// Events that need manual interaction from the user +/// +[Index(nameof(Timestamp), IsDescending = [true])] +[Index(nameof(Severity))] +[Index(nameof(Message))] +[Index(nameof(IsResolved))] +public class ManualEvent +{ + [Key] + public Guid Id { get; set; } = Guid.CreateVersion7(); + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + [Required] + [MaxLength(1000)] + public string Message { get; set; } = string.Empty; + + public string? Data { get; set; } + + [Required] + public required EventSeverity Severity { get; set; } + + public bool IsResolved { get; set; } +} \ No newline at end of file diff --git a/code/frontend/src/app/core/models/event.models.ts b/code/frontend/src/app/core/models/event.models.ts index 07377f7c..8b8fd077 100644 --- a/code/frontend/src/app/core/models/event.models.ts +++ b/code/frontend/src/app/core/models/event.models.ts @@ -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 }[]; diff --git a/code/frontend/src/app/core/services/app-hub.service.ts b/code/frontend/src/app/core/services/app-hub.service.ts index 6c2bd789..ef4e7318 100644 --- a/code/frontend/src/app/core/services/app-hub.service.ts +++ b/code/frontend/src/app/core/services/app-hub.service.ts @@ -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(false); private logsSubject = new BehaviorSubject([]); private eventsSubject = new BehaviorSubject([]); + private manualEventsSubject = new BehaviorSubject([]); private appStatusSubject = new BehaviorSubject(null); private jobsSubject = new BehaviorSubject([]); 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 { + 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); + } } diff --git a/code/frontend/src/app/core/services/manual-events.service.ts b/code/frontend/src/app/core/services/manual-events.service.ts new file mode 100644 index 00000000..7790db48 --- /dev/null +++ b/code/frontend/src/app/core/services/manual-events.service.ts @@ -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 { + 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> { + 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>(this.baseUrl, { params }); + } + + /** + * Get a specific manual event by ID + */ + getManualEvent(id: string): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + /** + * Mark a manual event as resolved + */ + resolveManualEvent(id: string): Observable { + return this.http.post(`${this.baseUrl}/${id}/resolve`, {}); + } + + /** + * Get manual event statistics + */ + getManualEventStats(): Observable { + return this.http.get(`${this.baseUrl}/stats`); + } + + /** + * Get available severities + */ + getSeverities(): Observable { + return this.http.get(`${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 }); + } +} diff --git a/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.html b/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.html index f320b771..63afd334 100644 --- a/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.html +++ b/code/frontend/src/app/dashboard/dashboard-page/dashboard-page.component.html @@ -7,7 +7,104 @@
- + + +
+
+ + +
+
+
+ +

Action Required

+
+ Manual intervention needed +
+
+ +
+
+
+ +
+ +
+

+

+ {{ currentManualEvent()!.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }} +

+
+ + +
+ + + + + @if (active) { + + } @else { + + } + + Event Details + + +
+
{{ parseEventData(currentManualEvent()!.data) | json }}
+
+
+
+
+
+ + + +
+
+
+
+
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'; + } + } }