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

View File

@@ -8,6 +8,15 @@ export interface AppEvent {
trackingId?: string;
}
export interface ManualEvent {
id: string;
timestamp: Date;
message: string;
data?: string;
severity: string;
isResolved: boolean;
}
export interface EventStats {
totalEvents: number;
eventsBySeverity: { severity: string; count: number }[];

View File

@@ -2,7 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import * as signalR from '@microsoft/signalr';
import { LogEntry } from '../models/signalr.models';
import { AppEvent } from '../models/event.models';
import { AppEvent, ManualEvent } from '../models/event.models';
import { AppStatus } from '../models/app-status.model';
import { JobInfo } from '../models/job.models';
import { ApplicationPathService } from './base-path.service';
@@ -18,12 +18,14 @@ export class AppHubService {
private connectionStatusSubject = new BehaviorSubject<boolean>(false);
private logsSubject = new BehaviorSubject<LogEntry[]>([]);
private eventsSubject = new BehaviorSubject<AppEvent[]>([]);
private manualEventsSubject = new BehaviorSubject<ManualEvent[]>([]);
private appStatusSubject = new BehaviorSubject<AppStatus | null>(null);
private jobsSubject = new BehaviorSubject<JobInfo[]>([]);
private readonly ApplicationPathService = inject(ApplicationPathService);
private logBuffer: LogEntry[] = [];
private eventBuffer: AppEvent[] = [];
private manualEventBuffer: ManualEvent[] = [];
private readonly bufferSize = 1000;
constructor() { }
@@ -117,6 +119,24 @@ export class AppHubService {
}
});
// Handle individual manual event messages
this.hubConnection.on('ManualEventReceived', (event: ManualEvent) => {
this.addManualEventToBuffer(event);
const currentEvents = this.manualEventsSubject.value;
this.manualEventsSubject.next([...currentEvents, event]);
});
// Handle bulk manual event messages (initial load)
this.hubConnection.on('ManualEventsReceived', (events: ManualEvent[]) => {
if (events && events.length > 0) {
// Set all manual events at once
this.manualEventsSubject.next(events);
// Update buffer
this.manualEventBuffer = [...events];
this.trimBuffer(this.manualEventBuffer, this.bufferSize);
}
});
this.hubConnection.on('AppStatusUpdated', (status: AppStatus | null) => {
if (!status) {
this.appStatusSubject.next(null);
@@ -158,6 +178,7 @@ export class AppHubService {
private requestInitialData(): void {
this.requestRecentLogs();
this.requestRecentEvents();
this.requestRecentManualEvents();
this.requestJobStatus();
}
@@ -180,12 +201,22 @@ export class AppHubService {
.catch(err => console.error('Error requesting recent events:', err));
}
}
/**
* Request recent manual events from the server
*/
public requestRecentManualEvents(count: number = 100): void {
if (this.isConnected()) {
this.hubConnection.invoke('GetRecentManualEvents', count)
.catch(err => console.error('Error requesting recent manual events:', err));
}
}
/**
* Check if the connection is established
*/
private isConnected(): boolean {
return this.hubConnection &&
return this.hubConnection &&
this.hubConnection.state === signalR.HubConnectionState.Connected;
}
@@ -222,7 +253,15 @@ export class AppHubService {
this.eventBuffer.push(event);
this.trimBuffer(this.eventBuffer, this.bufferSize);
}
/**
* Add a manual event to the buffer
*/
private addManualEventToBuffer(event: ManualEvent): void {
this.manualEventBuffer.push(event);
this.trimBuffer(this.manualEventBuffer, this.bufferSize);
}
/**
* Trim a buffer to the specified size
*/
@@ -248,6 +287,13 @@ export class AppHubService {
return this.eventsSubject.asObservable();
}
/**
* Get manual events as an observable
*/
public getManualEvents(): Observable<ManualEvent[]> {
return this.manualEventsSubject.asObservable();
}
/**
* Get jobs as an observable
*/
@@ -307,7 +353,15 @@ export class AppHubService {
this.eventsSubject.next([]);
this.eventBuffer = [];
}
/**
* Clear manual events
*/
public clearManualEvents(): void {
this.manualEventsSubject.next([]);
this.manualEventBuffer = [];
}
/**
* Clear logs
*/
@@ -315,4 +369,13 @@ export class AppHubService {
this.logsSubject.next([]);
this.logBuffer = [];
}
/**
* Remove a specific manual event from the subject
*/
public removeManualEvent(eventId: string): void {
const currentEvents = this.manualEventsSubject.value;
const filteredEvents = currentEvents.filter(e => e.id !== eventId);
this.manualEventsSubject.next(filteredEvents);
}
}

View File

@@ -0,0 +1,109 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ManualEvent } from '../models/event.models';
import { ApplicationPathService } from './base-path.service';
export interface PaginatedResult<T> {
items: T[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
export interface ManualEventFilter {
page?: number;
pageSize?: number;
isResolved?: boolean;
severity?: string;
fromDate?: Date;
toDate?: Date;
search?: string;
}
export interface ManualEventStats {
totalEvents: number;
unresolvedEvents: number;
resolvedEvents: number;
eventsBySeverity: { severity: string; count: number }[];
unresolvedBySeverity: { severity: string; count: number }[];
}
@Injectable({
providedIn: 'root'
})
export class ManualEventsService {
private readonly http = inject(HttpClient);
private readonly applicationPathService = inject(ApplicationPathService);
private readonly baseUrl = this.applicationPathService.buildApiUrl('/manualevents');
/**
* Get manual events with pagination and filtering
*/
getManualEvents(filter?: ManualEventFilter): Observable<PaginatedResult<ManualEvent>> {
let params = new HttpParams();
if (filter) {
if (filter.page !== undefined) {
params = params.set('page', filter.page.toString());
}
if (filter.pageSize !== undefined) {
params = params.set('pageSize', filter.pageSize.toString());
}
if (filter.isResolved !== undefined) {
params = params.set('isResolved', filter.isResolved.toString());
}
if (filter.severity) {
params = params.set('severity', filter.severity);
}
if (filter.fromDate) {
params = params.set('fromDate', filter.fromDate.toISOString());
}
if (filter.toDate) {
params = params.set('toDate', filter.toDate.toISOString());
}
if (filter.search) {
params = params.set('search', filter.search);
}
}
return this.http.get<PaginatedResult<ManualEvent>>(this.baseUrl, { params });
}
/**
* Get a specific manual event by ID
*/
getManualEvent(id: string): Observable<ManualEvent> {
return this.http.get<ManualEvent>(`${this.baseUrl}/${id}`);
}
/**
* Mark a manual event as resolved
*/
resolveManualEvent(id: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/${id}/resolve`, {});
}
/**
* Get manual event statistics
*/
getManualEventStats(): Observable<ManualEventStats> {
return this.http.get<ManualEventStats>(`${this.baseUrl}/stats`);
}
/**
* Get available severities
*/
getSeverities(): Observable<string[]> {
return this.http.get<string[]>(`${this.baseUrl}/severities`);
}
/**
* Trigger cleanup of old resolved events
*/
cleanupOldResolvedEvents(retentionDays: number = 30): Observable<{ deletedCount: number }> {
const params = new HttpParams().set('retentionDays', retentionDays.toString());
return this.http.post<{ deletedCount: number }>(`${this.baseUrl}/cleanup`, {}, { params });
}
}

View File

@@ -7,7 +7,104 @@
<div class="mb-4" *ngIf="showSupportSection()">
<app-support-section></app-support-section>
</div>
<!-- Manual Events Card (Action Required) -->
<div class="mb-4" *ngIf="currentManualEvent()">
<div class="glow-event-card-wrapper" [ngClass]="getManualEventSeverityClass(currentManualEvent()!.severity)">
<p-card styleClass="dashboard-card">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<div class="flex align-items-center gap-2">
<i class="pi pi-exclamation-triangle text-2xl"></i>
<h2 class="card-title m-0">Action Required</h2>
</div>
<span class="card-subtitle">Manual intervention needed</span>
</div>
<div class="flex align-items-center gap-2">
<p-tag
[severity]="getEventSeverity(currentManualEvent()!.severity)"
[value]="currentManualEvent()!.severity"
></p-tag>
</div>
</div>
</ng-template>
<div class="card-content">
<!-- Event Message -->
<div class="event-message mb-3">
<h3 class="text-xl font-semibold mb-2" [innerHTML]="processManualEventMessage(currentManualEvent()!.message)"></h3>
<p class="text-sm text-color-secondary">
{{ currentManualEvent()!.timestamp | date: 'yyyy-MM-dd HH:mm:ss' }}
</p>
</div>
<!-- Event Data (Collapsible) -->
<div class="event-data mb-3" *ngIf="currentManualEvent()!.data">
<p-accordion [multiple]="false">
<p-accordion-panel [value]="0">
<p-accordion-header>
<ng-template #toggleicon let-active="active">
@if (active) {
<i class="pi pi-chevron-up"></i>
} @else {
<i class="pi pi-chevron-down"></i>
}
</ng-template>
Event Details
</p-accordion-header>
<p-accordion-content>
<div class="event-data-content">
<pre class="text-sm">{{ parseEventData(currentManualEvent()!.data) | json }}</pre>
</div>
</p-accordion-content>
</p-accordion-panel>
</p-accordion>
</div>
<!-- Navigation and Action Controls -->
<div class="glow-card-footer">
<div class="flex align-items-center justify-content-between gap-3">
<!-- Navigation -->
<div class="flex align-items-center gap-2">
<button
pButton
icon="pi pi-chevron-left"
class="p-button-outlined p-button-sm"
(click)="previousManualEvent()"
[disabled]="!canNavigatePrevious()"
pTooltip="Previous event"
tooltipPosition="top"
></button>
<span class="text-sm font-medium">
{{ currentManualEventIndex() + 1 }} of {{ unresolvedManualEvents().length }}
</span>
<button
pButton
icon="pi pi-chevron-right"
class="p-button-outlined p-button-sm"
(click)="nextManualEvent()"
[disabled]="!canNavigateNext()"
pTooltip="Next event"
tooltipPosition="top"
></button>
</div>
<!-- Dismiss Button -->
<button
pButton
label="Dismiss"
icon="pi pi-check"
class="p-button-success"
(click)="dismissManualEvent(currentManualEvent()!.id)"
></button>
</div>
</div>
</div>
</p-card>
</div>
</div>
<!-- Real-time Cards -->
<div class="grid">
<!-- Recent Logs Card -->

View File

@@ -1,3 +1,224 @@
// Glowing event card wrapper styles
.glow-event-card-wrapper {
position: relative;
border-radius: 10px;
animation: slideInUp 0.6s ease-out forwards, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s;
::ng-deep .dashboard-card.p-card {
border: 3px solid !important;
border-radius: 10px !important;
box-shadow: none !important;
}
// Override hover effect from dashboard-card
::ng-deep .dashboard-card.p-card:hover {
transform: none !important;
box-shadow: none !important;
}
&.severity-error {
box-shadow:
0 0 20px rgba(239, 68, 68, 0.7),
0 0 40px rgba(239, 68, 68, 0.5),
0 0 60px rgba(239, 68, 68, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderError 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--red-500) !important;
}
}
&.severity-warning {
box-shadow:
0 0 20px rgba(234, 179, 8, 0.7),
0 0 40px rgba(234, 179, 8, 0.5),
0 0 60px rgba(234, 179, 8, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderWarning 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--yellow-500) !important;
}
}
&.severity-important {
box-shadow:
0 0 20px rgba(249, 115, 22, 0.7),
0 0 40px rgba(249, 115, 22, 0.5),
0 0 60px rgba(249, 115, 22, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderImportant 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--orange-500) !important;
}
}
&.severity-info {
box-shadow:
0 0 20px rgba(59, 130, 246, 0.7),
0 0 40px rgba(59, 130, 246, 0.5),
0 0 60px rgba(59, 130, 246, 0.3);
animation: slideInUp 0.6s ease-out forwards, glowBorderInfo 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--blue-500) !important;
}
}
&.severity-default {
box-shadow:
0 0 20px rgba(100, 100, 100, 0.5),
0 0 40px rgba(100, 100, 100, 0.3),
0 0 60px rgba(100, 100, 100, 0.2);
animation: slideInUp 0.6s ease-out forwards, glowBorderDefault 2s ease-in-out infinite, shake 3s ease-in-out infinite;
animation-delay: 0s, 0.6s, 0.6s;
::ng-deep .dashboard-card.p-card {
border-color: var(--surface-border) !important;
}
}
.event-data-content {
max-height: 300px;
overflow-y: auto;
background: var(--surface-50);
border-radius: 6px;
padding: 1rem;
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Courier New', monospace;
}
}
.glow-card-footer {
border-top: 1px solid var(--surface-border);
padding-top: 1rem;
margin-top: 1rem;
@media (max-width: 768px) {
.flex.align-items-center.justify-content-between {
flex-direction: column;
align-items: stretch;
gap: 1rem;
.flex.align-items-center.gap-2 {
justify-content: center;
}
button {
width: 100%;
}
}
}
}
}
// Glowing border animations
@keyframes glowBorderError {
0%, 100% {
box-shadow:
0 0 20px rgba(239, 68, 68, 0.7),
0 0 40px rgba(239, 68, 68, 0.5),
0 0 60px rgba(239, 68, 68, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(239, 68, 68, 0.9),
0 0 60px rgba(239, 68, 68, 0.7),
0 0 90px rgba(239, 68, 68, 0.5);
}
}
@keyframes glowBorderImportant {
0%, 100% {
box-shadow:
0 0 20px rgba(249, 115, 22, 0.7),
0 0 40px rgba(249, 115, 22, 0.5),
0 0 60px rgba(249, 115, 22, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(249, 115, 22, 0.9),
0 0 60px rgba(249, 115, 22, 0.7),
0 0 90px rgba(249, 115, 22, 0.5);
}
}
@keyframes glowBorderWarning {
0%, 100% {
box-shadow:
0 0 20px rgba(234, 179, 8, 0.7),
0 0 40px rgba(234, 179, 8, 0.5),
0 0 60px rgba(234, 179, 8, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(234, 179, 8, 0.9),
0 0 60px rgba(234, 179, 8, 0.7),
0 0 90px rgba(234, 179, 8, 0.5);
}
}
@keyframes glowBorderInfo {
0%, 100% {
box-shadow:
0 0 20px rgba(59, 130, 246, 0.7),
0 0 40px rgba(59, 130, 246, 0.5),
0 0 60px rgba(59, 130, 246, 0.3);
}
50% {
box-shadow:
0 0 35px rgba(59, 130, 246, 0.9),
0 0 60px rgba(59, 130, 246, 0.7),
0 0 90px rgba(59, 130, 246, 0.5);
}
}
@keyframes glowBorderDefault {
0%, 100% {
box-shadow:
0 0 20px rgba(100, 100, 100, 0.5),
0 0 40px rgba(100, 100, 100, 0.3),
0 0 60px rgba(100, 100, 100, 0.2);
}
50% {
box-shadow:
0 0 35px rgba(100, 100, 100, 0.7),
0 0 60px rgba(100, 100, 100, 0.5),
0 0 90px rgba(100, 100, 100, 0.3);
}
}
// Shake animation
@keyframes shake {
0%,
100% {
transform: translateX(0) rotate(0deg);
}
2% {
transform: translateX(-3px) rotate(-0.5deg);
}
4% {
transform: translateX(3px) rotate(0.5deg);
}
6% {
transform: translateX(-3px) rotate(-0.5deg);
}
8% {
transform: translateX(3px) rotate(0.5deg);
}
10% {
transform: translateX(0) rotate(0deg);
}
}
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
@@ -571,4 +792,16 @@
display: none;
}
}
}
// Slide in up animation for manual event card
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -9,12 +9,14 @@ import { ButtonModule } from 'primeng/button';
import { TagModule } from 'primeng/tag';
import { TooltipModule } from 'primeng/tooltip';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { AccordionModule } from 'primeng/accordion';
// Services & Models
import { AppHubService } from '../../core/services/app-hub.service';
import { ConfigurationService } from '../../core/services/configuration.service';
import { ManualEventsService } from '../../core/services/manual-events.service';
import { LogEntry } from '../../core/models/signalr.models';
import { AppEvent } from '../../core/models/event.models';
import { AppEvent, ManualEvent } from '../../core/models/event.models';
import { GeneralConfig } from '../../shared/models/general-config.model';
// Components
@@ -34,6 +36,7 @@ import { JobsManagementComponent } from '../../shared/components/jobs-management
TagModule,
TooltipModule,
ProgressSpinnerModule,
AccordionModule,
SupportSectionComponent,
JobsManagementComponent
],
@@ -43,11 +46,14 @@ import { JobsManagementComponent } from '../../shared/components/jobs-management
export class DashboardPageComponent implements OnInit, OnDestroy {
private appHubService = inject(AppHubService);
private configurationService = inject(ConfigurationService);
private manualEventsService = inject(ManualEventsService);
private destroy$ = new Subject<void>();
// Signals for reactive state
recentLogs = signal<LogEntry[]>([]);
recentEvents = signal<AppEvent[]>([]);
manualEvents = signal<ManualEvent[]>([]);
currentManualEventIndex = signal<number>(0);
connected = signal<boolean>(false);
generalConfig = signal<GeneralConfig | null>(null);
@@ -57,13 +63,38 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort chronologically (oldest first)
.slice(-5); // Take the last 10 (most recent);
});
displayEvents = computed(() => {
return this.recentEvents()
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) // Sort chronologically (oldest first)
.slice(-5); // Take the last 10 (most recent)
});
// Filter only unresolved manual events, sorted oldest first
unresolvedManualEvents = computed(() => {
return this.manualEvents()
.filter(e => !e.isResolved)
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
});
// Get the current manual event to display
currentManualEvent = computed(() => {
const events = this.unresolvedManualEvents();
const index = this.currentManualEventIndex();
return events.length > 0 && index < events.length ? events[index] : null;
});
// Check if we can navigate to previous event
canNavigatePrevious = computed(() => {
return this.currentManualEventIndex() > 0;
});
// Check if we can navigate to next event
canNavigateNext = computed(() => {
const events = this.unresolvedManualEvents();
return this.currentManualEventIndex() < events.length - 1;
});
// Computed value for showing support section
showSupportSection = computed(() => {
return this.generalConfig()?.displaySupportBanner ?? false;
@@ -112,6 +143,13 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
this.recentEvents.set(events);
});
// Subscribe to manual events
this.appHubService.getManualEvents()
.pipe(takeUntil(this.destroy$))
.subscribe((events: ManualEvent[]) => {
this.manualEvents.set(events);
});
// Subscribe to connection status
this.appHubService.getConnectionStatus()
.pipe(takeUntil(this.destroy$))
@@ -272,4 +310,94 @@ export class DashboardPageComponent implements OnInit, OnDestroy {
// Convert PascalCase to readable format
return eventType.replace(/([A-Z])/g, ' $1').trim();
}
// Manual event navigation methods
nextManualEvent(): void {
if (this.canNavigateNext()) {
this.currentManualEventIndex.update(i => i + 1);
}
}
previousManualEvent(): void {
if (this.canNavigatePrevious()) {
this.currentManualEventIndex.update(i => i - 1);
}
}
dismissManualEvent(eventId: string): void {
this.manualEventsService.resolveManualEvent(eventId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
// Remove from local state immediately
this.appHubService.removeManualEvent(eventId);
// Adjust index if needed
const events = this.unresolvedManualEvents();
const currentIndex = this.currentManualEventIndex();
if (currentIndex >= events.length && currentIndex > 0) {
// If we dismissed the last event, go to the previous one
this.currentManualEventIndex.set(events.length - 1);
} else if (events.length === 0) {
// Reset to 0 if no more events
this.currentManualEventIndex.set(0);
}
// Otherwise, stay at the same index (which now shows the next event)
},
error: (error) => {
console.error('Failed to dismiss manual event:', error);
}
});
}
// Helper to parse JSON data safely
parseEventData(data: string | undefined): any {
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
// Process message to convert URLs to clickable links and handle newlines
processManualEventMessage(message: string): string {
if (!message) return '';
// First, escape HTML to prevent XSS
const escaped = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// Convert newlines to <br> tags
let processed = escaped.replace(/\\n/g, '<br>').replace(/\n/g, '<br>');
// Convert URLs to clickable links
const urlRegex = /(https?:\/\/[^\s<]+)/g;
processed = processed.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="manual-event-link">$1</a>');
return processed;
}
// Get severity class for manual events
getManualEventSeverityClass(severity: string): string {
const normalizedSeverity = severity?.toLowerCase() || '';
switch (normalizedSeverity) {
case 'error':
return 'severity-error';
case 'warning':
return 'severity-warning';
case 'information':
return 'severity-info';
case 'important':
return 'severity-important';
default:
return 'severity-default';
}
}
}