Add handling for items that are not being blocked (#346)

This commit is contained in:
Flaminel
2025-10-23 18:12:42 +03:00
committed by GitHub
parent 6aac35181b
commit bf826da1ae
22 changed files with 1259 additions and 23 deletions

View File

@@ -0,0 +1,180 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ManualEventsController : ControllerBase
{
private readonly EventsContext _context;
public ManualEventsController(EventsContext context)
{
_context = context;
}
/// <summary>
/// Gets manual events with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 100,
[FromQuery] bool? isResolved = null,
[FromQuery] string? severity = null,
[FromQuery] DateTime? fromDate = null,
[FromQuery] DateTime? toDate = null,
[FromQuery] string? search = null)
{
// Validate pagination parameters
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 100;
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
var query = _context.ManualEvents.AsQueryable();
// Apply filters
if (isResolved.HasValue)
{
query = query.Where(e => e.IsResolved == isResolved.Value);
}
if (!string.IsNullOrWhiteSpace(severity))
{
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
query = query.Where(e => e.Severity == severityEnum);
}
// Apply date range filters
if (fromDate.HasValue)
{
query = query.Where(e => e.Timestamp >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern)
);
}
// Count total matching records for pagination
var totalCount = await query.CountAsync();
// Calculate pagination
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
// Get paginated data
var events = await query
.OrderByDescending(e => e.Timestamp)
.Skip(skip)
.Take(pageSize)
.ToListAsync();
// Return paginated result
var result = new PaginatedResult<ManualEvent>
{
Items = events,
Page = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(result);
}
/// <summary>
/// Gets a specific manual event by ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<ManualEvent>> GetManualEvent(Guid id)
{
var eventEntity = await _context.ManualEvents.FindAsync(id);
if (eventEntity == null)
return NotFound();
return Ok(eventEntity);
}
/// <summary>
/// Marks a manual event as resolved
/// </summary>
[HttpPost("{id}/resolve")]
public async Task<ActionResult> ResolveManualEvent(Guid id)
{
var eventEntity = await _context.ManualEvents.FindAsync(id);
if (eventEntity == null)
return NotFound();
eventEntity.IsResolved = true;
await _context.SaveChangesAsync();
return Ok();
}
/// <summary>
/// Gets manual event statistics
/// </summary>
[HttpGet("stats")]
public async Task<ActionResult<object>> GetManualEventStats()
{
var stats = new
{
TotalEvents = await _context.ManualEvents.CountAsync(),
UnresolvedEvents = await _context.ManualEvents.CountAsync(e => !e.IsResolved),
ResolvedEvents = await _context.ManualEvents.CountAsync(e => e.IsResolved),
EventsBySeverity = await _context.ManualEvents
.GroupBy(e => e.Severity)
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
.ToListAsync(),
UnresolvedBySeverity = await _context.ManualEvents
.Where(e => !e.IsResolved)
.GroupBy(e => e.Severity)
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
.ToListAsync()
};
return Ok(stats);
}
/// <summary>
/// Gets unique severities for manual events
/// </summary>
[HttpGet("severities")]
public async Task<ActionResult<List<string>>> GetSeverities()
{
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
/// <summary>
/// Manually triggers cleanup of old resolved events
/// </summary>
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
var deletedCount = await _context.ManualEvents
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
return Ok(new { DeletedCount = deletedCount });
}
}

View File

@@ -65,6 +65,10 @@ public class EventCleanupService : BackgroundService
await context.Events
.Where(e => e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
await context.ManualEvents
.Where(e => e.Timestamp < cutoffDate)
.Where(e => e.IsResolved)
.ExecuteDeleteAsync();
}
catch (Exception ex)
{

View File

@@ -1,4 +1,3 @@
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr.Queue;
@@ -8,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -44,7 +44,7 @@ public class EventPublisher
/// </summary>
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null)
{
var eventEntity = new AppEvent
AppEvent eventEntity = new()
{
EventType = eventType,
Message = message,
@@ -64,6 +64,27 @@ public class EventPublisher
_logger.LogTrace("Published event: {eventType}", eventType);
}
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null)
{
ManualEvent eventEntity = new()
{
Message = message,
Severity = severity,
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
}) : null,
};
// Save to database with dry run interception
await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity);
// Always send to SignalR clients (not affected by dry run)
await NotifyClientsAsync(eventEntity);
_logger.LogTrace("Published manual event: {message}", message);
}
/// <summary>
/// Publishes a strike event with context data and notifications
@@ -163,8 +184,8 @@ public class EventPublisher
public async Task PublishCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
// Get context data for the event
string downloadName = ContextProvider.Get<string>("downloadName") ?? "Unknown";
string hash = ContextProvider.Get<string>("hash") ?? "Unknown";
string downloadName = ContextProvider.Get<string>("downloadName");
string hash = ContextProvider.Get<string>("hash");
// Publish the event
await PublishAsync(
@@ -177,11 +198,48 @@ public class EventPublisher
await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag);
}
/// <summary>
/// Publishes an event alerting that an item keeps coming back
/// </summary>
public async Task PublishRecurringItem(string hash, string itemName, int strikeCount)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
// Publish the event
await PublishManualAsync(
"Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/",
EventSeverity.Important,
data: new { itemName, hash, strikeCount, instanceType, instanceUrl }
);
}
/// <summary>
/// Publishes an event alerting that search was not triggered for an item
/// </summary>
public async Task PublishSearchNotTriggered(string hash, string itemName)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
await PublishManualAsync(
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
EventSeverity.Warning,
data: new { itemName, hash, instanceType, instanceUrl }
);
}
private async Task SaveEventToDatabase(AppEvent eventEntity)
{
_context.Events.Add(eventEntity);
await _context.SaveChangesAsync();
}
private async Task SaveManualEventToDatabase(ManualEvent eventEntity)
{
_context.ManualEvents.Add(eventEntity);
await _context.SaveChangesAsync();
}
private async Task NotifyClientsAsync(AppEvent appEventEntity)
{
@@ -195,4 +253,17 @@ public class EventPublisher
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
}
}
private async Task NotifyClientsAsync(ManualEvent appEventEntity)
{
try
{
// Send to all connected clients via the unified AppHub
await _appHubContext.Clients.All.SendAsync("ManualEventReceived", appEventEntity);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
}
}
}

View File

@@ -7,28 +7,33 @@ using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Data.Models.Arr;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadRemover;
public sealed class QueueItemRemover : IQueueItemRemover
{
private readonly ILogger<QueueItemRemover> _logger;
private readonly IBus _messageBus;
private readonly IMemoryCache _cache;
private readonly ArrClientFactory _arrClientFactory;
private readonly EventPublisher _eventPublisher;
public QueueItemRemover(
ILogger<QueueItemRemover> logger,
IBus messageBus,
IMemoryCache cache,
ArrClientFactory arrClientFactory,
EventPublisher eventPublisher
)
{
_logger = logger;
_messageBus = messageBus;
_cache = cache;
_arrClientFactory = arrClientFactory;
@@ -53,6 +58,15 @@ public sealed class QueueItemRemover : IQueueItemRemover
// Use the new centralized EventPublisher method
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);
// If recurring, do not search for replacement
string hash = request.Record.DownloadId.ToLowerInvariant();
if (Striker.RecurringHashes.ContainsKey(hash))
{
await _eventPublisher.PublishSearchNotTriggered(request.Record.DownloadId, request.Record.Title);
Striker.RecurringHashes.Remove(hash, out _);
return;
}
await _messageBus.Publish(new DownloadHuntRequest<T>
{
InstanceType = request.InstanceType,

View File

@@ -4,6 +4,14 @@ namespace Cleanuparr.Infrastructure.Features.ItemStriker;
public interface IStriker
{
/// <summary>
/// Strikes an item and checks if it has reached the maximum strikes limit
/// </summary>
/// <param name="hash">The hash of the item</param>
/// <param name="itemName">The name of the item</param>
/// <param name="maxStrikes">The maximum number of strikes</param>
/// <param name="strikeType">The strike type</param>
/// <returns>True if the limit has been reached, otherwise false</returns>
Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType);
Task ResetStrikeAsync(string hash, string itemName, StrikeType strikeType);
}

View File

@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using System.Collections.Concurrent;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Shared.Helpers;
@@ -14,6 +15,8 @@ public sealed class Striker : IStriker
private readonly MemoryCacheEntryOptions _cacheOptions;
private readonly EventPublisher _eventPublisher;
public static readonly ConcurrentDictionary<string, string?> RecurringHashes = [];
public Striker(ILogger<Striker> logger, IMemoryCache cache, EventPublisher eventPublisher)
{
_logger = logger;
@@ -23,6 +26,7 @@ public sealed class Striker : IStriker
.SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer);
}
/// <inheritdoc/>
public async Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType)
{
if (maxStrikes is 0)
@@ -56,7 +60,9 @@ public sealed class Striker : IStriker
if (strikeCount > maxStrikes)
{
_logger.LogWarning("Blocked item keeps coming back | {name}", itemName);
_logger.LogWarning("Be sure to enable \"Reject Blocklisted Torrent Hashes While Grabbing\" on your indexers to reject blocked items");
RecurringHashes.TryAdd(hash.ToLowerInvariant(), null);
await _eventPublisher.PublishRecurringItem(hash, itemName, strikeCount);
}
_logger.LogInformation("Removing item with max strikes | reason {reason} | {name}", strikeType.ToString(), itemName);

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.Notifications;
@@ -122,7 +123,7 @@ public class NotificationPublisher : INotificationPublisher
var instanceUrl = ContextProvider.Get<Uri>(nameof(ArrInstance) + nameof(ArrInstance.Url));
var imageUrl = GetImageFromContext(record, instanceType);
return new NotificationContext
NotificationContext context = new()
{
EventType = eventType,
Title = $"Strike received with reason: {strikeType}",
@@ -138,6 +139,14 @@ public class NotificationPublisher : INotificationPublisher
["Url"] = instanceUrl.ToString(),
}
};
if (strikeType is StrikeType.Stalled or StrikeType.SlowSpeed or StrikeType.SlowTime)
{
var rule = ContextProvider.Get<QueueRule>();
context.Data.Add("Rule name", rule.Name);
}
return context;
}
private NotificationContext BuildQueueItemDeletedContext(bool removeFromClient, DeleteReason reason)

View File

@@ -67,6 +67,28 @@ public class AppHub : Hub
}
}
/// <summary>
/// Client requests recent manual events
/// </summary>
public async Task GetRecentManualEvents(int count = 100)
{
try
{
var manualEvents = await _context.ManualEvents
.Where(e => !e.IsResolved)
.OrderBy(e => e.Timestamp) // Oldest first
.Take(Math.Min(count, 100)) // Cap at 100
.ToListAsync();
await Clients.Caller.SendAsync("ManualEventsReceived", manualEvents);
_logger.LogDebug("Sent {count} recent manual events to client {connectionId}", manualEvents.Count, Context.ConnectionId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send recent manual events to client");
}
}
/// <summary>
/// Client requests current job statuses
/// </summary>

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Cache;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Helpers;
@@ -48,6 +49,7 @@ public class RuleEvaluator : IRuleEvaluator
}
_logger.LogTrace("Applying stall rule {rule} | {name}", rule.Name, torrent.Name);
ContextProvider.Set<QueueRule>(rule);
await ResetStalledStrikesAsync(
torrent,
@@ -85,6 +87,7 @@ public class RuleEvaluator : IRuleEvaluator
}
_logger.LogTrace("Applying slow rule {rule} | {name}", rule.Name, torrent.Name);
ContextProvider.Set<QueueRule>(rule);
// Check if slow speed
if (!string.IsNullOrWhiteSpace(rule.MinSpeed))

View File

@@ -13,6 +13,8 @@ public class EventsContext : DbContext
{
public DbSet<AppEvent> Events { get; set; }
public DbSet<ManualEvent> ManualEvents { get; set; }
public EventsContext()
{
}

View File

@@ -0,0 +1,128 @@
// <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("20251023105637_AddManualEvents")]
partial class AddManualEvents
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("event_type");
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.Property<Guid?>("TrackingId")
.HasColumnType("TEXT")
.HasColumnName("tracking_id");
b.HasKey("Id")
.HasName("pk_events");
b.HasIndex("EventType")
.HasDatabaseName("ix_events_event_type");
b.HasIndex("Message")
.HasDatabaseName("ix_events_message");
b.HasIndex("Severity")
.HasDatabaseName("ix_events_severity");
b.HasIndex("Timestamp")
.IsDescending()
.HasDatabaseName("ix_events_timestamp");
b.ToTable("events", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<bool>("IsResolved")
.HasColumnType("INTEGER")
.HasColumnName("is_resolved");
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("IsResolved")
.HasDatabaseName("ix_manual_events_is_resolved");
b.HasIndex("Message")
.HasDatabaseName("ix_manual_events_message");
b.HasIndex("Severity")
.HasDatabaseName("ix_manual_events_severity");
b.HasIndex("Timestamp")
.IsDescending()
.HasDatabaseName("ix_manual_events_timestamp");
b.ToTable("manual_events", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Events
{
/// <inheritdoc />
public partial class AddManualEvents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "manual_events",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
timestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
message = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
data = table.Column<string>(type: "TEXT", nullable: true),
severity = table.Column<string>(type: "TEXT", nullable: false),
is_resolved = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_manual_events", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_manual_events_is_resolved",
table: "manual_events",
column: "is_resolved");
migrationBuilder.CreateIndex(
name: "ix_manual_events_message",
table: "manual_events",
column: "message");
migrationBuilder.CreateIndex(
name: "ix_manual_events_severity",
table: "manual_events",
column: "severity");
migrationBuilder.CreateIndex(
name: "ix_manual_events_timestamp",
table: "manual_events",
column: "timestamp",
descending: [true]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "manual_events");
}
}
}

View File

@@ -1,6 +1,6 @@
// <auto-generated />
using System;
using Data;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -15,9 +15,9 @@ namespace Cleanuparr.Persistence.Migrations.Events
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.5");
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.Entity("Data.Models.Events.AppEvent", b =>
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -70,6 +70,55 @@ namespace Cleanuparr.Persistence.Migrations.Events
b.ToTable("events", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<bool>("IsResolved")
.HasColumnType("INTEGER")
.HasColumnName("is_resolved");
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("IsResolved")
.HasDatabaseName("ix_manual_events_is_resolved");
b.HasIndex("Message")
.HasDatabaseName("ix_manual_events_message");
b.HasIndex("Severity")
.HasDatabaseName("ix_manual_events_severity");
b.HasIndex("Timestamp")
.IsDescending()
.HasDatabaseName("ix_manual_events_timestamp");
b.ToTable("manual_events", (string)null);
});
#pragma warning restore 612, 618
}
}

View File

@@ -11,7 +11,7 @@ namespace Cleanuparr.Persistence.Models.Events;
[Index(nameof(EventType))]
[Index(nameof(Severity))]
[Index(nameof(Message))]
public class AppEvent
public class AppEvent : IEvent
{
[Key]
public Guid Id { get; set; } = Guid.CreateVersion7();
@@ -25,9 +25,7 @@ public class AppEvent
[MaxLength(1000)]
public string Message { get; set; } = string.Empty;
/// <summary>
/// JSON data associated with the event
/// </summary>
/// <inheritdoc/>
public string? Data { get; set; }
[Required]

View File

@@ -0,0 +1,13 @@
namespace Cleanuparr.Persistence.Models.Events;
public interface IEvent
{
Guid Id { get; set; }
DateTime Timestamp { get; set; }
/// <summary>
/// JSON data associated with the event
/// </summary>
string? Data { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Persistence.Models.Events;
/// <summary>
/// Events that need manual interaction from the user
/// </summary>
[Index(nameof(Timestamp), IsDescending = [true])]
[Index(nameof(Severity))]
[Index(nameof(Message))]
[Index(nameof(IsResolved))]
public class ManualEvent
{
[Key]
public Guid Id { get; set; } = Guid.CreateVersion7();
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[Required]
[MaxLength(1000)]
public string Message { get; set; } = string.Empty;
public string? Data { get; set; }
[Required]
public required EventSeverity Severity { get; set; }
public bool IsResolved { get; set; }
}