Add strikes page (#438)

This commit is contained in:
Flaminel
2026-02-15 03:57:14 +02:00
committed by GitHub
parent 701829001c
commit 97eb2fce44
25 changed files with 1548 additions and 21 deletions

View File

@@ -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;
}
/// <summary>
/// Gets download items with their strikes (grouped), with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<DownloadItemStrikesDto>>> 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<StrikeType>(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<DownloadItemStrikesDto>
{
Items = dtos,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages,
});
}
/// <summary>
/// Gets the most recent individual strikes with download item info (for dashboard)
/// </summary>
[HttpGet("recent")]
public async Task<ActionResult<List<RecentStrikeDto>>> 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);
}
/// <summary>
/// Gets all available strike types
/// </summary>
[HttpGet("types")]
public ActionResult<List<string>> GetStrikeTypes()
{
var types = Enum.GetNames(typeof(StrikeType)).ToList();
return Ok(types);
}
/// <summary>
/// Deletes all strikes for a specific download item
/// </summary>
[HttpDelete("{downloadItemId:guid}")]
public async Task<IActionResult> 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<string, int> 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<StrikeDetailDto> 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;
}

View File

@@ -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<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_eventsContext = new EventsContext(eventsContextOptions);
_eventsContext = TestEventsContextFactory.Create();
var hubContextMock = new Mock<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
@@ -59,18 +56,10 @@ public class QueueItemRemoverTests : IDisposable
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
// 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<Delegate>(), It.IsAny<object[]>()))
.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

View File

@@ -0,0 +1,31 @@
using Cleanuparr.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
/// <summary>
/// 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.
/// </summary>
public static class TestEventsContextFactory
{
/// <summary>
/// Creates a new SQLite in-memory EventsContext with schema initialized
/// </summary>
public static EventsContext Create()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.Options;
var context = new EventsContext(options);
context.Database.EnsureCreated();
return context;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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<QueueItemRemover> 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<T>(QueueItemRemoveRequest<T> 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);

View File

@@ -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);

View File

@@ -89,6 +89,35 @@ public class AppHub : Hub
}
}
/// <summary>
/// Client requests recent strikes
/// </summary>
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");
}
}
/// <summary>
/// Client requests current job statuses
/// </summary>

View File

@@ -0,0 +1,378 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("download_client_name");
b.Property<string>("DownloadClientType")
.HasColumnType("TEXT")
.HasColumnName("download_client_type");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("event_type");
b.Property<string>("InstanceType")
.HasColumnType("TEXT")
.HasColumnName("instance_type");
b.Property<string>("InstanceUrl")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("instance_url");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<Guid?>("StrikeId")
.HasColumnType("TEXT")
.HasColumnName("strike_id");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT")
.HasColumnName("timestamp");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("download_client_name");
b.Property<string>("DownloadClientType")
.HasColumnType("TEXT")
.HasColumnName("download_client_type");
b.Property<string>("InstanceType")
.HasColumnType("TEXT")
.HasColumnName("instance_type");
b.Property<string>("InstanceUrl")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("instance_url");
b.Property<bool>("IsResolved")
.HasColumnType("INTEGER")
.HasColumnName("is_resolved");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("DownloadId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("download_id");
b.Property<bool>("IsMarkedForRemoval")
.HasColumnType("INTEGER")
.HasColumnName("is_marked_for_removal");
b.Property<bool>("IsRemoved")
.HasColumnType("INTEGER")
.HasColumnName("is_removed");
b.Property<bool>("IsReturning")
.HasColumnType("INTEGER")
.HasColumnName("is_returning");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<DateTime>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<Guid>("DownloadItemId")
.HasColumnType("TEXT")
.HasColumnName("download_item_id");
b.Property<Guid>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<long?>("LastDownloadedBytes")
.HasColumnType("INTEGER")
.HasColumnName("last_downloaded_bytes");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Events
{
/// <inheritdoc />
public partial class AddDownloadItemStatuses : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_marked_for_removal",
table: "download_items",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_removed",
table: "download_items",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_returning",
table: "download_items",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -199,6 +199,18 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("download_id");
b.Property<bool>("IsMarkedForRemoval")
.HasColumnType("INTEGER")
.HasColumnName("is_marked_for_removal");
b.Property<bool>("IsRemoved")
.HasColumnType("INTEGER")
.HasColumnName("is_removed");
b.Property<bool>("IsReturning")
.HasColumnType("INTEGER")
.HasColumnName("is_returning");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)

View File

@@ -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<Strike> Strikes { get; set; } = [];
}

View File

@@ -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: [

View File

@@ -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';

View File

@@ -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<PaginatedResult<DownloadItemStrikes>> {
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<PaginatedResult<DownloadItemStrikes>>('/api/strikes', { params });
}
getRecentStrikes(count = 5): Observable<RecentStrike[]> {
const params = new HttpParams().set('count', count);
return this.http.get<RecentStrike[]>('/api/strikes/recent', { params });
}
getStrikeTypes(): Observable<string[]> {
return this.http.get<string[]>('/api/strikes/types');
}
deleteStrikesForItem(downloadItemId: string): Observable<void> {
return this.http.delete<void>(`/api/strikes/${downloadItemId}`);
}
}

View File

@@ -0,0 +1,36 @@
export interface DownloadItemStrikes {
downloadItemId: string;
downloadId: string;
title: string;
isMarkedForRemoval: boolean;
isRemoved: boolean;
isReturning: boolean;
totalStrikes: number;
strikesByType: Record<string, number>;
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;
}

View File

@@ -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<LogEntry[]>([]);
private readonly _events = signal<AppEvent[]>([]);
private readonly _manualEvents = signal<ManualEvent[]>([]);
private readonly _strikes = signal<RecentStrike[]>([]);
private readonly _jobs = signal<JobInfo[]>([]);
private readonly _appStatus = signal<AppStatus | null>(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');
}

View File

@@ -87,6 +87,52 @@
}
<div class="dashboard-grid">
<!-- Recent Strikes -->
<app-card [noPadding]="true" class="dashboard-grid__strikes">
<div class="card-inner card-inner--compact">
<div class="card-header">
<div class="card-header__left">
<h3 class="card-header__title">Recent Strikes</h3>
<app-badge [severity]="connected() ? 'success' : 'error'" size="sm" [rounded]="true" [class.badge--live]="connected()">
{{ connected() ? 'Connected' : 'Disconnected' }}
</app-badge>
</div>
<a class="card-header__link" routerLink="/strikes">View All</a>
</div>
<div class="timeline">
<span class="timeline__direction">
<ng-icon name="tablerArrowDown" class="timeline__direction-icon" />
Newest first
</span>
@for (strike of recentStrikes(); track strike.id) {
<div class="timeline__item">
<div class="timeline__marker timeline__marker--warning">
<ng-icon name="tablerBolt" />
</div>
<div class="timeline__content">
<div class="timeline__row">
<app-badge [severity]="strikeTypeSeverity(strike.type)" size="sm">
{{ formatStrikeType(strike.type) }}
</app-badge>
<span class="timeline__time">{{ strike.createdAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
<p class="timeline__message">{{ truncate(strike.title) }}</p>
</div>
</div>
} @empty {
<div class="timeline__empty">
@if (!connected()) {
<app-spinner size="sm" />
<span>Connecting...</span>
} @else {
<span>No recent strikes</span>
}
</div>
}
</div>
</div>
</app-card>
<!-- Recent Logs -->
<app-card [noPadding]="true">
<div class="card-inner">
@@ -166,7 +212,7 @@
<app-badge [severity]="eventSeverity(event.severity)" size="sm">
{{ event.severity }}
</app-badge>
<app-badge severity="default" size="sm">
<app-badge [severity]="eventTypeSeverity(event.eventType)" size="sm">
{{ formatEventType(event.eventType) }}
</app-badge>
<span class="timeline__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</span>

View File

@@ -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 {

View File

@@ -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();
}
}

View File

@@ -93,7 +93,7 @@
<app-badge [severity]="eventSeverity(event.severity)" size="sm">
{{ event.severity }}
</app-badge>
<app-badge severity="default" size="sm">
<app-badge [severity]="eventTypeSeverity(event.eventType)" size="sm">
{{ formatEventType(event.eventType) }}
</app-badge>
@if (event.trackingId) {

View File

@@ -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';

View File

@@ -0,0 +1,140 @@
<app-page-header
title="Strikes"
subtitle="Download items with active strikes"
/>
<div class="page-content">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar__filters">
<app-select
placeholder="All Types"
[options]="typeOptions()"
[(value)]="selectedType"
(valueChange)="onFilterChange()"
/>
<app-input
placeholder="Search by title or hash..."
type="search"
[(value)]="searchQuery"
(blurred)="onFilterChange()"
/>
</div>
<div class="toolbar__actions">
<app-button variant="ghost" size="sm" (clicked)="refresh()">
Refresh
</app-button>
</div>
</div>
<!-- Count -->
<div class="strike-count">
<app-animated-counter [value]="totalRecords()" [duration]="400" /> download items with strikes
</div>
<!-- Strikes List -->
<app-card [noPadding]="true">
<div class="strikes-list">
@for (item of items(); track item.downloadItemId) {
<div
class="strike-row"
[class.strike-row--expanded]="expandedId() === item.downloadItemId"
>
<div
class="strike-row__main"
(click)="toggleExpand(item.downloadItemId)"
>
<ng-icon name="tablerBolt" class="strike-row__icon" />
<span class="strike-row__title">{{ item.title }}</span>
@if (item.isMarkedForRemoval) {
<app-badge severity="warning" size="sm">Marked for Removal</app-badge>
}
@if (item.isRemoved) {
<app-badge severity="error" size="sm">Removed</app-badge>
}
@if (item.isReturning) {
<app-badge severity="warning" size="sm">Returning</app-badge>
}
<span class="strike-row__hash">{{ item.downloadId }}</span>
<div class="strike-row__type-badges">
@for (entry of strikeTypeEntries(item.strikesByType); track entry.type) {
<app-badge [severity]="strikeTypeSeverity(entry.type)" size="sm">
{{ formatStrikeType(entry.type) }} &times;{{ entry.count }}
</app-badge>
}
</div>
<span class="strike-row__total">Total: {{ item.totalStrikes }}</span>
<span class="strike-row__time">{{ item.latestStrikeAt | date:'yyyy-MM-dd HH:mm' }}</span>
<button
class="strike-row__delete"
(click)="deleteItemStrikes(item); $event.stopPropagation()"
title="Delete all strikes for this item"
>
<ng-icon name="tablerTrash" />
</button>
<ng-icon
[name]="expandedId() === item.downloadItemId ? 'tablerChevronUp' : 'tablerChevronDown'"
class="strike-row__chevron"
/>
</div>
@if (expandedId() === item.downloadItemId) {
<div class="strike-row__details">
<div class="strike-row__detail">
<span class="strike-row__detail-label">Download Hash</span>
<span class="strike-row__detail-value strike-row__detail-value--mono">{{ item.downloadId }}</span>
</div>
<div class="strike-row__detail">
<span class="strike-row__detail-label">First Strike</span>
<span class="strike-row__detail-value">{{ item.firstStrikeAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
<div class="strike-row__detail">
<span class="strike-row__detail-label">Latest Strike</span>
<span class="strike-row__detail-value">{{ item.latestStrikeAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
<!-- Individual strikes table -->
<div class="strike-row__detail">
<span class="strike-row__detail-label">Individual Strikes</span>
<div class="strike-table">
<div class="strike-table__header">
<span>Type</span>
<span>Timestamp</span>
<span>Downloaded</span>
<span>Job Run</span>
</div>
@for (strike of item.strikes; track strike.id) {
<div class="strike-table__row">
<app-badge [severity]="strikeTypeSeverity(strike.type)" size="sm">
{{ formatStrikeType(strike.type) }}
</app-badge>
<span class="strike-table__time">{{ strike.createdAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
<span class="strike-table__bytes">{{ formatBytes(strike.lastDownloadedBytes) }}</span>
<span class="strike-table__run-id">{{ strike.jobRunId }}</span>
</div>
}
</div>
</div>
</div>
}
</div>
} @empty {
<app-empty-state
icon="tablerBolt"
heading="No strikes"
description="No download items have active strikes."
/>
}
</div>
</app-card>
<!-- Pagination -->
@if (totalRecords() > pageSize()) {
<app-paginator
[totalRecords]="totalRecords()"
[pageSize]="pageSize()"
[currentPage]="currentPage()"
(pageChange)="onPageChange($event)"
/>
}
</div>

View File

@@ -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;
}
}
}

View File

@@ -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<typeof setInterval> | null = null;
readonly items = signal<DownloadItemStrikes[]>([]);
readonly totalRecords = signal(0);
readonly loading = signal(false);
readonly expandedId = signal<string | null>(null);
readonly currentPage = signal(1);
readonly pageSize = signal(50);
readonly selectedType = signal<unknown>('');
readonly searchQuery = signal('');
readonly typeOptions = signal<SelectOption[]>([{ 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<void> {
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<string, number>): { type: string; count: number }[] {
return Object.entries(strikesByType).map(([type, count]) => ({ type, count }));
}
}

View File

@@ -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[] = [