mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-04-04 22:34:31 -04:00
Add strikes page (#438)
This commit is contained in:
189
code/backend/Cleanuparr.Api/Controllers/StrikesController.cs
Normal file
189
code/backend/Cleanuparr.Api/Controllers/StrikesController.cs
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
378
code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.Designer.cs
generated
Normal file
378
code/backend/Cleanuparr.Persistence/Migrations/Events/20260214230732_AddDownloadItemStatuses.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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';
|
||||
|
||||
34
code/frontend/src/app/core/api/strikes.api.ts
Normal file
34
code/frontend/src/app/core/api/strikes.api.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
36
code/frontend/src/app/core/models/strike.models.ts
Normal file
36
code/frontend/src/app/core/models/strike.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
140
code/frontend/src/app/features/strikes/strikes.component.html
Normal file
140
code/frontend/src/app/features/strikes/strikes.component.html
Normal 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) }} ×{{ 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>
|
||||
300
code/frontend/src/app/features/strikes/strikes.component.scss
Normal file
300
code/frontend/src/app/features/strikes/strikes.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
164
code/frontend/src/app/features/strikes/strikes.component.ts
Normal file
164
code/frontend/src/app/features/strikes/strikes.component.ts
Normal 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 }));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user