From 97eb2fce44298fe16b10cf57b2b18cee60a72cc9 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 15 Feb 2026 03:57:14 +0200 Subject: [PATCH] Add strikes page (#438) --- .../Controllers/StrikesController.cs | 189 +++++++++ .../DownloadRemover/QueueItemRemoverTests.cs | 22 +- .../TestHelpers/TestEventsContextFactory.cs | 31 ++ .../Events/EventPublisher.cs | 23 ++ .../DownloadRemover/QueueItemRemover.cs | 16 +- .../Features/ItemStriker/Striker.cs | 17 +- .../Cleanuparr.Infrastructure/Hubs/AppHub.cs | 29 ++ ...230732_AddDownloadItemStatuses.Designer.cs | 378 ++++++++++++++++++ .../20260214230732_AddDownloadItemStatuses.cs | 51 +++ .../Events/EventsContextModelSnapshot.cs | 12 + .../Models/State/DownloadItem.cs | 4 + code/frontend/src/app/app.routes.ts | 7 + code/frontend/src/app/core/api/index.ts | 1 + code/frontend/src/app/core/api/strikes.api.ts | 34 ++ .../src/app/core/models/strike.models.ts | 36 ++ .../src/app/core/realtime/app-hub.service.ts | 21 + .../dashboard/dashboard.component.html | 48 ++- .../dashboard/dashboard.component.scss | 11 +- .../features/dashboard/dashboard.component.ts | 23 ++ .../app/features/events/events.component.html | 2 +- .../app/features/events/events.component.ts | 9 + .../features/strikes/strikes.component.html | 140 +++++++ .../features/strikes/strikes.component.scss | 300 ++++++++++++++ .../app/features/strikes/strikes.component.ts | 164 ++++++++ .../nav-sidebar/nav-sidebar.component.ts | 1 + 25 files changed, 1548 insertions(+), 21 deletions(-) create mode 100644 code/backend/Cleanuparr.Api/Controllers/StrikesController.cs create mode 100644 code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestEventsContextFactory.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.cs create mode 100644 code/frontend/src/app/core/api/strikes.api.ts create mode 100644 code/frontend/src/app/core/models/strike.models.ts create mode 100644 code/frontend/src/app/features/strikes/strikes.component.html create mode 100644 code/frontend/src/app/features/strikes/strikes.component.scss create mode 100644 code/frontend/src/app/features/strikes/strikes.component.ts diff --git a/code/backend/Cleanuparr.Api/Controllers/StrikesController.cs b/code/backend/Cleanuparr.Api/Controllers/StrikesController.cs new file mode 100644 index 00000000..588b7604 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Controllers/StrikesController.cs @@ -0,0 +1,189 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.State; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class StrikesController : ControllerBase +{ + private readonly EventsContext _context; + + public StrikesController(EventsContext context) + { + _context = context; + } + + /// + /// Gets download items with their strikes (grouped), with pagination and filtering + /// + [HttpGet] + public async Task>> GetStrikes( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] string? search = null, + [FromQuery] string? type = null) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > 100) pageSize = 100; + + var query = _context.DownloadItems + .Include(d => d.Strikes) + .Where(d => d.Strikes.Any()); + + // Filter by strike type: only show items that have strikes of this type + if (!string.IsNullOrWhiteSpace(type)) + { + if (Enum.TryParse(type, true, out var strikeType)) + query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType)); + } + + // Apply search filter on title or download hash + if (!string.IsNullOrWhiteSpace(search)) + { + string pattern = EventsContext.GetLikePattern(search); + query = query.Where(d => + EF.Functions.Like(d.Title, pattern) || + EF.Functions.Like(d.DownloadId, pattern)); + } + + var totalCount = await query.CountAsync(); + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var skip = (page - 1) * pageSize; + + var items = await query + .OrderByDescending(d => d.Strikes.Max(s => s.CreatedAt)) + .Skip(skip) + .Take(pageSize) + .ToListAsync(); + + var dtos = items.Select(d => new DownloadItemStrikesDto + { + DownloadItemId = d.Id, + DownloadId = d.DownloadId, + Title = d.Title, + TotalStrikes = d.Strikes.Count, + StrikesByType = d.Strikes + .GroupBy(s => s.Type) + .ToDictionary(g => g.Key.ToString(), g => g.Count()), + LatestStrikeAt = d.Strikes.Max(s => s.CreatedAt), + FirstStrikeAt = d.Strikes.Min(s => s.CreatedAt), + IsMarkedForRemoval = d.IsMarkedForRemoval, + IsRemoved = d.IsRemoved, + IsReturning = d.IsReturning, + Strikes = d.Strikes + .OrderByDescending(s => s.CreatedAt) + .Select(s => new StrikeDetailDto + { + Id = s.Id, + Type = s.Type.ToString(), + CreatedAt = s.CreatedAt, + LastDownloadedBytes = s.LastDownloadedBytes, + JobRunId = s.JobRunId, + }).ToList(), + }).ToList(); + + return Ok(new PaginatedResult + { + Items = dtos, + Page = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages, + }); + } + + /// + /// Gets the most recent individual strikes with download item info (for dashboard) + /// + [HttpGet("recent")] + public async Task>> GetRecentStrikes( + [FromQuery] int count = 5) + { + if (count < 1) count = 1; + if (count > 50) count = 50; + + var strikes = await _context.Strikes + .Include(s => s.DownloadItem) + .OrderByDescending(s => s.CreatedAt) + .Take(count) + .Select(s => new RecentStrikeDto + { + Id = s.Id, + Type = s.Type.ToString(), + CreatedAt = s.CreatedAt, + DownloadId = s.DownloadItem.DownloadId, + Title = s.DownloadItem.Title, + }) + .ToListAsync(); + + return Ok(strikes); + } + + /// + /// Gets all available strike types + /// + [HttpGet("types")] + public ActionResult> GetStrikeTypes() + { + var types = Enum.GetNames(typeof(StrikeType)).ToList(); + return Ok(types); + } + + /// + /// Deletes all strikes for a specific download item + /// + [HttpDelete("{downloadItemId:guid}")] + public async Task DeleteStrikesForItem(Guid downloadItemId) + { + var item = await _context.DownloadItems + .Include(d => d.Strikes) + .FirstOrDefaultAsync(d => d.Id == downloadItemId); + + if (item == null) + return NotFound(); + + _context.Strikes.RemoveRange(item.Strikes); + _context.DownloadItems.Remove(item); + await _context.SaveChangesAsync(); + + return NoContent(); + } +} + +public class DownloadItemStrikesDto +{ + public Guid DownloadItemId { get; set; } + public string DownloadId { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public int TotalStrikes { get; set; } + public Dictionary StrikesByType { get; set; } = new(); + public DateTime LatestStrikeAt { get; set; } + public DateTime FirstStrikeAt { get; set; } + public bool IsMarkedForRemoval { get; set; } + public bool IsRemoved { get; set; } + public bool IsReturning { get; set; } + public List Strikes { get; set; } = []; +} + +public class StrikeDetailDto +{ + public Guid Id { get; set; } + public string Type { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public long? LastDownloadedBytes { get; set; } + public Guid JobRunId { get; set; } +} + +public class RecentStrikeDto +{ + public Guid Id { get; set; } + public string Type { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public string DownloadId { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs index aa1b0b37..efe2bc29 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -10,12 +10,12 @@ using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Hubs; using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Data.Models.Arr; using MassTransit; using Microsoft.AspNetCore.SignalR; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -48,10 +48,7 @@ public class QueueItemRemoverTests : IDisposable .Returns(_arrClientMock.Object); // Create real EventPublisher with mocked dependencies - var eventsContextOptions = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - _eventsContext = new EventsContext(eventsContextOptions); + _eventsContext = TestEventsContextFactory.Create(); var hubContextMock = new Mock>(); var clientsMock = new Mock(); @@ -59,18 +56,10 @@ public class QueueItemRemoverTests : IDisposable hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object); var dryRunInterceptorMock = new Mock(); - // Setup interceptor to execute the action with params using DynamicInvoke + // Setup interceptor to skip actual database saves (these tests verify QueueItemRemover, not EventPublisher) dryRunInterceptorMock .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) - .Returns((Delegate action, object[] parameters) => - { - var result = action.DynamicInvoke(parameters); - if (result is Task task) - { - return task; - } - return Task.CompletedTask; - }); + .Returns(Task.CompletedTask); _eventPublisher = new EventPublisher( _eventsContext, @@ -84,7 +73,8 @@ public class QueueItemRemoverTests : IDisposable _busMock.Object, _memoryCache, _arrClientFactoryMock.Object, - _eventPublisher + _eventPublisher, + _eventsContext ); // Clear static RecurringHashes before each test diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestEventsContextFactory.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestEventsContextFactory.cs new file mode 100644 index 00000000..80d9996a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestEventsContextFactory.cs @@ -0,0 +1,31 @@ +using Cleanuparr.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; + +/// +/// Factory for creating SQLite in-memory EventsContext instances for testing. +/// SQLite in-memory supports ExecuteUpdateAsync, ExecuteDeleteAsync, and EF.Functions.Like, +/// unlike the EF Core InMemory provider. +/// +public static class TestEventsContextFactory +{ + /// + /// Creates a new SQLite in-memory EventsContext with schema initialized + /// + public static EventsContext Create() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var context = new EventsContext(options); + context.Database.EnsureCreated(); + + return context; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index 441b6399..e9774f08 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -147,6 +147,9 @@ public class EventPublisher : IEventPublisher data: data, strikeId: strikeId); + // Broadcast strike to SignalR clients for real-time dashboard updates + await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName); + // Send notification (uses ContextProvider internally) await _notificationPublisher.NotifyStrike(strikeType, strikeCount); } @@ -272,4 +275,24 @@ public class EventPublisher : IEventPublisher _logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id); } } + + private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName) + { + try + { + var strike = new + { + Id = strikeId ?? Guid.Empty, + Type = strikeType.ToString(), + CreatedAt = DateTime.UtcNow, + DownloadId = hash, + Title = itemName, + }; + await _appHubContext.Clients.All.SendAsync("StrikeReceived", strike); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send strike to SignalR clients"); + } + } } \ 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 ca369cae..ef2df6b8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -11,9 +11,11 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Helpers; +using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Data.Models.Arr; using MassTransit; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -26,13 +28,15 @@ public sealed class QueueItemRemover : IQueueItemRemover private readonly IMemoryCache _cache; private readonly IArrClientFactory _arrClientFactory; private readonly IEventPublisher _eventPublisher; + private readonly EventsContext _eventsContext; public QueueItemRemover( ILogger logger, IBus messageBus, IMemoryCache cache, IArrClientFactory arrClientFactory, - IEventPublisher eventPublisher + IEventPublisher eventPublisher, + EventsContext eventsContext ) { _logger = logger; @@ -40,6 +44,7 @@ public sealed class QueueItemRemover : IQueueItemRemover _cache = cache; _arrClientFactory = arrClientFactory; _eventPublisher = eventPublisher; + _eventsContext = eventsContext; } public async Task RemoveQueueItemAsync(QueueItemRemoveRequest request) @@ -50,6 +55,15 @@ public sealed class QueueItemRemover : IQueueItemRemover var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version); await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason); + // Mark the download item as removed in the database + await _eventsContext.DownloadItems + .Where(x => EF.Functions.Like(x.DownloadId, request.Record.DownloadId)) + .ExecuteUpdateAsync(setter => + { + setter.SetProperty(x => x.IsRemoved, true); + setter.SetProperty(x => x.IsMarkedForRemoval, false); + }); + // Set context for EventPublisher ContextProvider.SetJobRunId(request.JobRunId); ContextProvider.Set(ContextProvider.Keys.ItemName, request.Record.Title); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs index 82d40bbb..4c999dbb 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/ItemStriker/Striker.cs @@ -45,10 +45,25 @@ public sealed class Striker : IStriker LastDownloadedBytes = lastDownloadedBytes }; _context.Strikes.Add(strike); - await _context.SaveChangesAsync(); int strikeCount = existingStrikeCount + 1; + // If item was previously removed and gets a new strike, it has returned + if (downloadItem.IsRemoved) + { + downloadItem.IsReturning = true; + downloadItem.IsRemoved = false; + downloadItem.IsMarkedForRemoval = false; + } + + // Mark for removal when strike limit reached + if (strikeCount >= maxStrikes) + { + downloadItem.IsMarkedForRemoval = true; + } + + await _context.SaveChangesAsync(); + _logger.LogInformation("Item on strike number {strike} | reason {reason} | {name}", strikeCount, strikeType.ToString(), itemName); await _eventPublisher.PublishStrike(strikeType, strikeCount, hash, itemName, strike.Id); diff --git a/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs b/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs index 1b04d0a3..98a04859 100644 --- a/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs +++ b/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs @@ -89,6 +89,35 @@ public class AppHub : Hub } } + /// + /// Client requests recent strikes + /// + public async Task GetRecentStrikes(int count = 5) + { + try + { + var strikes = await _context.Strikes + .Include(s => s.DownloadItem) + .OrderByDescending(s => s.CreatedAt) + .Take(Math.Min(count, 50)) + .Select(s => new + { + s.Id, + Type = s.Type.ToString(), + s.CreatedAt, + DownloadId = s.DownloadItem.DownloadId, + Title = s.DownloadItem.Title, + }) + .ToListAsync(); + + await Clients.Caller.SendAsync("StrikesReceived", strikes); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send recent strikes to client"); + } + } + /// /// Client requests current job statuses /// diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.Designer.cs new file mode 100644 index 00000000..486a392a --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.Designer.cs @@ -0,0 +1,378 @@ +// +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("20260214230732_AddDownloadItemStatuses")] + partial class AddDownloadItemStatuses + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + 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("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("TEXT") + .HasColumnName("download_client_type"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + b.Property("InstanceType") + .HasColumnType("TEXT") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("instance_url"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("StrikeId") + .HasColumnType("TEXT") + .HasColumnName("strike_id"); + + b.Property("Timestamp") + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.Property("TrackingId") + .HasColumnType("TEXT") + .HasColumnName("tracking_id"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("DownloadClientType") + .HasDatabaseName("ix_events_download_client_type"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_type"); + + b.HasIndex("InstanceType") + .HasDatabaseName("ix_events_instance_type"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_events_job_run_id"); + + b.HasIndex("Message") + .HasDatabaseName("ix_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_events_severity"); + + b.HasIndex("StrikeId") + .HasDatabaseName("ix_events_strike_id"); + + 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("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("TEXT") + .HasColumnName("download_client_type"); + + b.Property("InstanceType") + .HasColumnType("TEXT") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("instance_url"); + + b.Property("IsResolved") + .HasColumnType("INTEGER") + .HasColumnName("is_resolved"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + 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("InstanceType") + .HasDatabaseName("ix_manual_events_instance_type"); + + b.HasIndex("IsResolved") + .HasDatabaseName("ix_manual_events_is_resolved"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_manual_events_job_run_id"); + + 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); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("download_id"); + + b.Property("IsMarkedForRemoval") + .HasColumnType("INTEGER") + .HasColumnName("is_marked_for_removal"); + + b.Property("IsRemoved") + .HasColumnType("INTEGER") + .HasColumnName("is_removed"); + + b.Property("IsReturning") + .HasColumnType("INTEGER") + .HasColumnName("is_returning"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_download_items"); + + b.HasIndex("DownloadId") + .IsUnique() + .HasDatabaseName("ix_download_items_download_id"); + + b.ToTable("download_items", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_job_runs"); + + b.HasIndex("StartedAt") + .IsDescending() + .HasDatabaseName("ix_job_runs_started_at"); + + b.HasIndex("Type") + .HasDatabaseName("ix_job_runs_type"); + + b.ToTable("job_runs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DownloadItemId") + .HasColumnType("TEXT") + .HasColumnName("download_item_id"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("LastDownloadedBytes") + .HasColumnType("INTEGER") + .HasColumnName("last_downloaded_bytes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_strikes"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_strikes_created_at"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_strikes_job_run_id"); + + b.HasIndex("DownloadItemId", "Type") + .HasDatabaseName("ix_strikes_download_item_id_type"); + + b.ToTable("strikes", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Events") + .HasForeignKey("JobRunId") + .HasConstraintName("fk_events_job_runs_job_run_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.Strike", "Strike") + .WithMany() + .HasForeignKey("StrikeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_events_strikes_strike_id"); + + b.Navigation("JobRun"); + + b.Navigation("Strike"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("ManualEvents") + .HasForeignKey("JobRunId") + .HasConstraintName("fk_manual_events_job_runs_job_run_id"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem") + .WithMany("Strikes") + .HasForeignKey("DownloadItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_download_items_download_item_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Strikes") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_job_runs_job_run_id"); + + b.Navigation("DownloadItem"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Navigation("Strikes"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Navigation("Events"); + + b.Navigation("ManualEvents"); + + b.Navigation("Strikes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.cs new file mode 100644 index 00000000..a1de61ab --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + /// + public partial class AddDownloadItemStatuses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_marked_for_removal", + table: "download_items", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "is_removed", + table: "download_items", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "is_returning", + table: "download_items", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "is_marked_for_removal", + table: "download_items"); + + migrationBuilder.DropColumn( + name: "is_removed", + table: "download_items"); + + migrationBuilder.DropColumn( + name: "is_returning", + table: "download_items"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs index 8b396d7d..a1b1f05b 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs @@ -199,6 +199,18 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("download_id"); + b.Property("IsMarkedForRemoval") + .HasColumnType("INTEGER") + .HasColumnName("is_marked_for_removal"); + + b.Property("IsRemoved") + .HasColumnType("INTEGER") + .HasColumnName("is_removed"); + + b.Property("IsReturning") + .HasColumnType("INTEGER") + .HasColumnName("is_returning"); + b.Property("Title") .IsRequired() .HasMaxLength(500) diff --git a/code/backend/Cleanuparr.Persistence/Models/State/DownloadItem.cs b/code/backend/Cleanuparr.Persistence/Models/State/DownloadItem.cs index 4ffd0124..522400f2 100644 --- a/code/backend/Cleanuparr.Persistence/Models/State/DownloadItem.cs +++ b/code/backend/Cleanuparr.Persistence/Models/State/DownloadItem.cs @@ -18,6 +18,10 @@ public class DownloadItem [MaxLength(500)] public required string Title { get; set; } + public bool IsMarkedForRemoval { get; set; } + public bool IsRemoved { get; set; } + public bool IsReturning { get; set; } + [JsonIgnore] public List Strikes { get; set; } = []; } diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index 6af5d612..0509b809 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -30,6 +30,13 @@ export const routes: Routes = [ (m) => m.EventsComponent, ), }, + { + path: 'strikes', + loadComponent: () => + import('@features/strikes/strikes.component').then( + (m) => m.StrikesComponent, + ), + }, { path: 'settings', children: [ diff --git a/code/frontend/src/app/core/api/index.ts b/code/frontend/src/app/core/api/index.ts index 5597b6f7..1b9a3b28 100644 --- a/code/frontend/src/app/core/api/index.ts +++ b/code/frontend/src/app/core/api/index.ts @@ -9,3 +9,4 @@ export { NotificationApi } from './notification.api'; export { JobsApi } from './jobs.api'; export { EventsApi } from './events.api'; export { SystemApi } from './system.api'; +export { StrikesApi } from './strikes.api'; diff --git a/code/frontend/src/app/core/api/strikes.api.ts b/code/frontend/src/app/core/api/strikes.api.ts new file mode 100644 index 00000000..1ef100e1 --- /dev/null +++ b/code/frontend/src/app/core/api/strikes.api.ts @@ -0,0 +1,34 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { DownloadItemStrikes, RecentStrike, StrikeFilter } from '@core/models/strike.models'; +import { PaginatedResult } from '@core/models/pagination.model'; + +@Injectable({ providedIn: 'root' }) +export class StrikesApi { + private http = inject(HttpClient); + + getStrikes(filter?: StrikeFilter): Observable> { + let params = new HttpParams(); + if (filter) { + if (filter.page) params = params.set('page', filter.page); + if (filter.pageSize) params = params.set('pageSize', filter.pageSize); + if (filter.search) params = params.set('search', filter.search); + if (filter.type) params = params.set('type', filter.type); + } + return this.http.get>('/api/strikes', { params }); + } + + getRecentStrikes(count = 5): Observable { + const params = new HttpParams().set('count', count); + return this.http.get('/api/strikes/recent', { params }); + } + + getStrikeTypes(): Observable { + return this.http.get('/api/strikes/types'); + } + + deleteStrikesForItem(downloadItemId: string): Observable { + return this.http.delete(`/api/strikes/${downloadItemId}`); + } +} diff --git a/code/frontend/src/app/core/models/strike.models.ts b/code/frontend/src/app/core/models/strike.models.ts new file mode 100644 index 00000000..3fb5d2b6 --- /dev/null +++ b/code/frontend/src/app/core/models/strike.models.ts @@ -0,0 +1,36 @@ +export interface DownloadItemStrikes { + downloadItemId: string; + downloadId: string; + title: string; + isMarkedForRemoval: boolean; + isRemoved: boolean; + isReturning: boolean; + totalStrikes: number; + strikesByType: Record; + latestStrikeAt: string; + firstStrikeAt: string; + strikes: StrikeDetail[]; +} + +export interface StrikeDetail { + id: string; + type: string; + createdAt: string; + lastDownloadedBytes: number | null; + jobRunId: string; +} + +export interface RecentStrike { + id: string; + type: string; + createdAt: string; + downloadId: string; + title: string; +} + +export interface StrikeFilter { + page?: number; + pageSize?: number; + search?: string; + type?: string; +} diff --git a/code/frontend/src/app/core/realtime/app-hub.service.ts b/code/frontend/src/app/core/realtime/app-hub.service.ts index 339d12b2..1540c124 100644 --- a/code/frontend/src/app/core/realtime/app-hub.service.ts +++ b/code/frontend/src/app/core/realtime/app-hub.service.ts @@ -5,6 +5,7 @@ import { SignalRHubConfig, LogEntry } from '@core/models/signalr.models'; import { AppEvent, ManualEvent } from '@core/models/event.models'; import { JobInfo } from '@core/models/job.models'; import { AppStatus } from '@core/models/app-status.model'; +import { RecentStrike } from '@core/models/strike.models'; const MAX_BUFFER = 1000; @@ -22,12 +23,14 @@ export class AppHubService extends HubService { private readonly _logs = signal([]); private readonly _events = signal([]); private readonly _manualEvents = signal([]); + private readonly _strikes = signal([]); private readonly _jobs = signal([]); private readonly _appStatus = signal(null); readonly logs = this._logs.asReadonly(); readonly events = this._events.asReadonly(); readonly manualEvents = this._manualEvents.asReadonly(); + readonly strikes = this._strikes.asReadonly(); readonly jobs = this._jobs.asReadonly(); readonly appStatus = this._appStatus.asReadonly(); @@ -71,6 +74,19 @@ export class AppHubService extends HubService { this._manualEvents.set(events); }); + // Single strike + connection.on('StrikeReceived', (strike: RecentStrike) => { + this._strikes.update((strikes) => { + const updated = [strike, ...strikes]; + return updated.length > MAX_BUFFER ? updated.slice(0, MAX_BUFFER) : updated; + }); + }); + + // Bulk initial strikes + connection.on('StrikesReceived', (strikes: RecentStrike[]) => { + this._strikes.set(strikes); + }); + // Jobs status connection.on('JobsStatusUpdate', (jobs: JobInfo[]) => { this._jobs.set(jobs); @@ -98,6 +114,7 @@ export class AppHubService extends HubService { this.requestRecentLogs(); this.requestRecentEvents(); this.requestRecentManualEvents(); + this.requestRecentStrikes(); this.requestJobStatus(); } @@ -117,6 +134,10 @@ export class AppHubService extends HubService { this.invoke('GetRecentManualEvents', count); } + requestRecentStrikes(count = 5): void { + this.invoke('GetRecentStrikes', count); + } + requestJobStatus(): void { this.invoke('GetJobStatus'); } diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.html b/code/frontend/src/app/features/dashboard/dashboard.component.html index 8d0ba3a0..30ade122 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.html +++ b/code/frontend/src/app/features/dashboard/dashboard.component.html @@ -87,6 +87,52 @@ }
+ + +
+
+
+

Recent Strikes

+ + {{ connected() ? 'Connected' : 'Disconnected' }} + +
+ View All +
+
+ + + Newest first + + @for (strike of recentStrikes(); track strike.id) { +
+
+ +
+
+
+ + {{ formatStrikeType(strike.type) }} + + {{ strike.createdAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+

{{ truncate(strike.title) }}

+
+
+ } @empty { +
+ @if (!connected()) { + + Connecting... + } @else { + No recent strikes + } +
+ } +
+
+
+
@@ -166,7 +212,7 @@ {{ event.severity }} - + {{ formatEventType(event.eventType) }} {{ event.timestamp | date:'yyyy-MM-dd HH:mm:ss' }} diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.scss b/code/frontend/src/app/features/dashboard/dashboard.component.scss index 785763fa..83021712 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.scss +++ b/code/frontend/src/app/features/dashboard/dashboard.component.scss @@ -119,11 +119,15 @@ grid-template-columns: 1fr; } + &__strikes { + grid-column: 1 / -1; + } + &__jobs { grid-column: 1 / -1; } - // Staggered entrance animations (Issue 1) + // Staggered entrance animations > app-card { min-width: 0; // Allow grid items to shrink below content size animation: slide-up var(--duration-normal) var(--ease-default) both; @@ -131,6 +135,7 @@ &:nth-child(1) { animation-delay: 0ms; } &:nth-child(2) { animation-delay: 80ms; } &:nth-child(3) { animation-delay: 160ms; } + &:nth-child(4) { animation-delay: 240ms; } } } @@ -289,6 +294,10 @@ display: flex; flex-direction: column; min-height: 320px; + + &--compact { + min-height: auto; + } } .card-header { diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.ts b/code/frontend/src/app/features/dashboard/dashboard.component.ts index 9095a8a2..0645e6f2 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.ts +++ b/code/frontend/src/app/features/dashboard/dashboard.component.ts @@ -43,6 +43,7 @@ export class DashboardComponent implements OnInit { readonly jobs = this.hub.jobs; readonly showSupportSection = signal(false); + readonly recentStrikes = computed(() => this.hub.strikes().slice(0, 5)); readonly recentLogs = computed(() => this.hub.logs().slice(0, 5)); readonly recentEvents = computed(() => this.hub.events().slice(0, 5)); @@ -135,6 +136,15 @@ export class DashboardComponent implements OnInit { return this.eventSeverity(severity); } + eventTypeSeverity(eventType: string): 'error' | 'warning' | 'info' | 'success' | 'default' { + const t = eventType.toLowerCase(); + if (t === 'failedimportstrike' || t === 'queueitemdeleted') return 'error'; + if (t === 'stalledstrike' || t === 'downloadmarkedfordeletion') return 'warning'; + if (t === 'downloadcleaned') return 'success'; + if (t.includes('strike') || t === 'categorychanged') return 'info'; + return 'default'; + } + eventSeverity(severity: string): 'error' | 'warning' | 'info' | 'default' { const s = severity.toLowerCase(); if (s === 'error') return 'error'; @@ -236,4 +246,17 @@ export class DashboardComponent implements OnInit { navigateTo(path: string): void { this.router.navigate([path]); } + + // Strike helpers + strikeTypeSeverity(type: string): 'error' | 'warning' | 'info' | 'default' { + const t = type.toLowerCase(); + if (t === 'failedimport') return 'error'; + if (t === 'stalled') return 'warning'; + if (t === 'slowspeed' || t === 'slowtime') return 'info'; + return 'default'; + } + + formatStrikeType(type: string): string { + return type.replace(/([A-Z])/g, ' $1').trim(); + } } diff --git a/code/frontend/src/app/features/events/events.component.html b/code/frontend/src/app/features/events/events.component.html index f85bd208..5a646b4c 100644 --- a/code/frontend/src/app/features/events/events.component.html +++ b/code/frontend/src/app/features/events/events.component.html @@ -93,7 +93,7 @@ {{ event.severity }} - + {{ formatEventType(event.eventType) }} @if (event.trackingId) { diff --git a/code/frontend/src/app/features/events/events.component.ts b/code/frontend/src/app/features/events/events.component.ts index 4fae79a3..7b1faa24 100644 --- a/code/frontend/src/app/features/events/events.component.ts +++ b/code/frontend/src/app/features/events/events.component.ts @@ -211,6 +211,15 @@ export class EventsComponent implements OnInit, OnDestroy { } // Helpers + eventTypeSeverity(eventType: string): 'error' | 'warning' | 'info' | 'success' | 'default' { + const t = eventType.toLowerCase(); + if (t === 'failedimportstrike' || t === 'queueitemdeleted') return 'error'; + if (t === 'stalledstrike' || t === 'downloadmarkedfordeletion') return 'warning'; + if (t === 'downloadcleaned') return 'success'; + if (t.includes('strike') || t === 'categorychanged') return 'info'; + return 'default'; + } + eventSeverity(severity: string): 'error' | 'warning' | 'info' | 'default' { const s = severity.toLowerCase(); if (s === 'error') return 'error'; diff --git a/code/frontend/src/app/features/strikes/strikes.component.html b/code/frontend/src/app/features/strikes/strikes.component.html new file mode 100644 index 00000000..eaf02b51 --- /dev/null +++ b/code/frontend/src/app/features/strikes/strikes.component.html @@ -0,0 +1,140 @@ + + +
+ +
+
+ + +
+
+ + Refresh + +
+
+ + +
+ download items with strikes +
+ + + +
+ @for (item of items(); track item.downloadItemId) { +
+
+ + {{ item.title }} + @if (item.isMarkedForRemoval) { + Marked for Removal + } + @if (item.isRemoved) { + Removed + } + @if (item.isReturning) { + Returning + } + {{ item.downloadId }} +
+ @for (entry of strikeTypeEntries(item.strikesByType); track entry.type) { + + {{ formatStrikeType(entry.type) }} ×{{ entry.count }} + + } +
+ Total: {{ item.totalStrikes }} + {{ item.latestStrikeAt | date:'yyyy-MM-dd HH:mm' }} + + +
+ + @if (expandedId() === item.downloadItemId) { +
+
+ Download Hash + {{ item.downloadId }} +
+
+ First Strike + {{ item.firstStrikeAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+
+ Latest Strike + {{ item.latestStrikeAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+ + +
+ Individual Strikes +
+
+ Type + Timestamp + Downloaded + Job Run +
+ @for (strike of item.strikes; track strike.id) { +
+ + {{ formatStrikeType(strike.type) }} + + {{ strike.createdAt | date:'yyyy-MM-dd HH:mm:ss' }} + {{ formatBytes(strike.lastDownloadedBytes) }} + {{ strike.jobRunId }} +
+ } +
+
+
+ } +
+ } @empty { + + } +
+
+ + + @if (totalRecords() > pageSize()) { + + } +
diff --git a/code/frontend/src/app/features/strikes/strikes.component.scss b/code/frontend/src/app/features/strikes/strikes.component.scss new file mode 100644 index 00000000..0e19caca --- /dev/null +++ b/code/frontend/src/app/features/strikes/strikes.component.scss @@ -0,0 +1,300 @@ +@use 'data-toolbar' as *; + +// Staggered page content animations +.page-content { + > .toolbar { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 0ms; + position: relative; + z-index: 1; + } + > .strike-count { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 40ms; + } + > app-card { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 80ms; + } + > app-paginator { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 120ms; + } +} + +.toolbar { + @include data-toolbar; + + &__filters { + app-input { + flex: 1; + min-width: 150px; + max-width: 400px; + } + } +} + +.strike-count { + font-size: var(--font-size-sm); + color: var(--text-tertiary); + margin-bottom: var(--space-3); +} + +// Strikes list +.strikes-list { + max-height: 70vh; + overflow-y: auto; +} + +.strike-row { + border-bottom: 1px solid var(--divider); + transition: background var(--duration-fast) var(--ease-default); + font-size: var(--font-size-sm); + position: relative; + overflow: hidden; + + // Glow rail + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + transform: translateX(-100%); + transition: transform var(--duration-normal) var(--ease-default); + pointer-events: none; + z-index: 0; + } + + &:hover::before { + transform: translateX(100%); + } + + &:hover { + background: var(--glass-bg); + } + + &--expanded { + background: var(--glass-bg); + } + + &__main { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + min-height: 44px; + cursor: pointer; + } + + &__icon { + color: var(--color-warning); + font-size: 18px; + flex-shrink: 0; + } + + &__title { + font-weight: 500; + color: var(--text-primary); + min-width: 0; + flex: 1; + word-break: break-word; + } + + &__hash { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + } + + &__type-badges { + display: flex; + gap: var(--space-1); + flex-shrink: 0; + flex-wrap: wrap; + } + + &__total { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-secondary); + flex-shrink: 0; + } + + &__time { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + } + + &__delete { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: var(--space-1); + border-radius: var(--radius-sm); + font-size: 16px; + flex-shrink: 0; + transition: color var(--duration-fast) var(--ease-default); + + &:hover { + color: var(--color-error); + } + } + + &__chevron { + font-size: 14px; + color: var(--text-tertiary); + flex-shrink: 0; + margin-left: auto; + transition: color var(--duration-fast) var(--ease-default); + } + + &__main:hover &__chevron { + color: var(--text-secondary); + } + + // Expanded details + &__details { + padding: var(--space-2) var(--space-4) var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); + animation: fade-in var(--duration-fast) var(--ease-default); + } + + &__detail { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__detail-label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__detail-value { + font-size: var(--font-size-sm); + color: var(--text-secondary); + word-break: break-word; + + &--mono { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + } + } +} + +// Individual strikes table +.strike-table { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + overflow: hidden; + + &__header { + display: grid; + grid-template-columns: 160px 180px 100px 1fr; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--divider); + } + + &__row { + display: grid; + grid-template-columns: 160px 180px 100px 1fr; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + align-items: center; + border-bottom: 1px solid var(--divider); + + &:last-child { + border-bottom: none; + } + } + + &__time { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-secondary); + } + + &__bytes { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + + &__run-id { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// Tablet responsiveness +@media (max-width: 1024px) { + .strike-row__main { + flex-wrap: wrap; + } + + .strike-row__hash { + display: none; + } + + .strike-table { + &__header, + &__row { + grid-template-columns: 140px 160px 80px 1fr; + } + } +} + +// Mobile responsiveness +@media (max-width: 768px) { + .strike-row__main { + flex-wrap: wrap; + padding: var(--space-2) var(--space-3); + } + + .strike-row__type-badges { + order: 3; + flex-basis: 100%; + } + + .strike-row__total, + .strike-row__time { + order: 4; + } + + .strike-row__hash { + display: none; + } + + .strike-table { + &__header { + display: none; + } + + &__row { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + } + } +} diff --git a/code/frontend/src/app/features/strikes/strikes.component.ts b/code/frontend/src/app/features/strikes/strikes.component.ts new file mode 100644 index 00000000..55aafa9b --- /dev/null +++ b/code/frontend/src/app/features/strikes/strikes.component.ts @@ -0,0 +1,164 @@ +import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { NgIcon } from '@ng-icons/core'; +import { PageHeaderComponent } from '@layout/page-header/page-header.component'; +import { + CardComponent, BadgeComponent, ButtonComponent, SelectComponent, + InputComponent, PaginatorComponent, EmptyStateComponent, type SelectOption, +} from '@ui'; +import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; +import { StrikesApi } from '@core/api/strikes.api'; +import { ToastService } from '@core/services/toast.service'; +import { ConfirmService } from '@core/services/confirm.service'; +import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models'; + +@Component({ + selector: 'app-strikes', + standalone: true, + imports: [ + DatePipe, + NgIcon, + PageHeaderComponent, + CardComponent, + BadgeComponent, + ButtonComponent, + SelectComponent, + InputComponent, + PaginatorComponent, + EmptyStateComponent, + AnimatedCounterComponent, + ], + templateUrl: './strikes.component.html', + styleUrl: './strikes.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StrikesComponent implements OnInit, OnDestroy { + private readonly strikesApi = inject(StrikesApi); + private readonly toast = inject(ToastService); + private readonly confirm = inject(ConfirmService); + private pollTimer: ReturnType | null = null; + + readonly items = signal([]); + readonly totalRecords = signal(0); + readonly loading = signal(false); + readonly expandedId = signal(null); + + readonly currentPage = signal(1); + readonly pageSize = signal(50); + readonly selectedType = signal(''); + readonly searchQuery = signal(''); + + readonly typeOptions = signal([{ label: 'All Types', value: '' }]); + + ngOnInit(): void { + this.loadStrikeTypes(); + this.loadStrikes(); + this.pollTimer = setInterval(() => this.loadStrikes(), 10_000); + } + + ngOnDestroy(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + } + } + + loadStrikes(): void { + const filter: StrikeFilter = { + page: this.currentPage(), + pageSize: this.pageSize(), + }; + const type = this.selectedType() as string; + const search = this.searchQuery(); + + if (type) filter.type = type; + if (search) filter.search = search; + + this.loading.set(true); + this.strikesApi.getStrikes(filter).subscribe({ + next: (result) => { + this.items.set(result.items); + this.totalRecords.set(result.totalCount); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.toast.error('Failed to load strikes'); + }, + }); + } + + private loadStrikeTypes(): void { + this.strikesApi.getStrikeTypes().subscribe({ + next: (types) => { + this.typeOptions.set([ + { label: 'All Types', value: '' }, + ...types.map((t) => ({ label: this.formatStrikeType(t), value: t })), + ]); + }, + }); + } + + onFilterChange(): void { + this.currentPage.set(1); + this.loadStrikes(); + } + + onPageChange(page: number): void { + this.currentPage.set(page); + this.loadStrikes(); + } + + toggleExpand(itemId: string): void { + this.expandedId.update((current) => (current === itemId ? null : itemId)); + } + + async deleteItemStrikes(item: DownloadItemStrikes): Promise { + const confirmed = await this.confirm.confirm({ + title: 'Delete Strikes', + message: `Delete all ${item.totalStrikes} strike(s) for "${item.title}"? This action cannot be undone.`, + confirmLabel: 'Delete', + destructive: true, + }); + + if (!confirmed) return; + + this.strikesApi.deleteStrikesForItem(item.downloadItemId).subscribe({ + next: () => { + this.toast.success(`Strikes deleted for "${item.title}"`); + this.loadStrikes(); + }, + error: () => this.toast.error('Failed to delete strikes'), + }); + } + + refresh(): void { + this.loadStrikes(); + } + + // Helpers + strikeTypeSeverity(type: string): 'error' | 'warning' | 'info' | 'default' { + const t = type.toLowerCase(); + if (t === 'failedimport') return 'error'; + if (t === 'stalled') return 'warning'; + if (t === 'slowspeed' || t === 'slowtime') return 'info'; + return 'default'; + } + + formatStrikeType(type: string): string { + return type.replace(/([A-Z])/g, ' $1').trim(); + } + + formatBytes(bytes: number | null): string { + if (bytes === null || bytes === undefined) return '-'; + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + strikeTypeEntries(strikesByType: Record): { type: string; count: number }[] { + return Object.entries(strikesByType).map(([type, count]) => ({ type, count })); + } + +} diff --git a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts index 69300ead..be2bdd12 100644 --- a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts +++ b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts @@ -47,6 +47,7 @@ export class NavSidebarComponent { { label: 'Dashboard', icon: 'tablerLayoutDashboard', route: '/dashboard' }, { label: 'Logs', icon: 'tablerFileText', route: '/logs' }, { label: 'Events', icon: 'tablerBell', route: '/events' }, + { label: 'Strikes', icon: 'tablerBolt', route: '/strikes' }, ]; settingsItems: NavItem[] = [