using Cleanuparr.Api.Common; using Cleanuparr.Api.Contracts.Responses; using Cleanuparr.Api.Features.Events.Contracts.Responses; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Stats; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Events; using Cleanuparr.Persistence.Providers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Cleanuparr.Api.Controllers; [ApiController] [Route("api/[controller]")] [Authorize] public class EventsController : ControllerBase { private readonly EventsContext _context; private readonly IDatabaseProvider _databaseProvider; public EventsController(EventsContext context, IDatabaseProvider databaseProvider) { _context = context; _databaseProvider = databaseProvider; } /// /// Gets events with pagination and filtering /// [HttpGet] public async Task>> GetEvents( [FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] string? severity = null, [FromQuery] string? eventType = null, [FromQuery] DateTimeOffset? fromDate = null, [FromQuery] DateTimeOffset? toDate = null, [FromQuery] string? search = null, [FromQuery] string? jobRunId = null) { // Validate pagination parameters if (page < 1) { page = 1; } if (pageSize < 1) { pageSize = 50; } if (pageSize > 500) { pageSize = 500; } IQueryable query = _context.Events .Select(EventListItem.FromEvent); // Apply filters if (!string.IsNullOrWhiteSpace(severity)) { if (EnumSentinel.TryParseSelectable(severity, out EventSeverity severityEnum)) { query = query.Where(e => e.Severity == severityEnum); } } if (!string.IsNullOrWhiteSpace(eventType)) { if (EnumSentinel.TryParseSelectable(eventType, out EventType eventTypeEnum)) { query = query.Where(e => e.EventType == eventTypeEnum); } } // 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 job run ID exact-match filter if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out Guid jobRunGuid)) { query = query.Where(e => e.JobRunId == jobRunGuid); } // Apply search filter if provided if (!string.IsNullOrWhiteSpace(search)) { string pattern = EventsContext.GetLikePattern(search); query = query.Where(e => EF.Functions.Like(e.Message.ToLower(), pattern, "\\") || (e.ItemTitle != null && EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\")) || EF.Functions.Like(e.TrackingId.ToString().ToLower(), pattern, "\\") || EF.Functions.Like(e.JobRunId.ToString().ToLower(), pattern, "\\") ); } // Count total matching records for pagination int totalCount = await query.CountAsync(); // Calculate pagination int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); int skip = (page - 1) * pageSize; List events = await query .OrderByDescending(e => e.Timestamp) .ThenByDescending(e => e.Id) .Skip(skip) .Take(pageSize) .ToListAsync(); // Return paginated result PaginatedResult result = new() { Items = events, Page = page, PageSize = pageSize, TotalCount = totalCount, TotalPages = totalPages }; return Ok(result); } /// /// Gets a specific event by ID /// [HttpGet("{id}")] public async Task> GetEvent(Guid id) { var eventEntity = await _context.Events.FindAsync(id); if (eventEntity == null) return NotFound(); return Ok(eventEntity); } /// /// Gets events by tracking ID /// [HttpGet("tracking/{trackingId}")] public async Task>> GetEventsByTracking(Guid trackingId) { var events = await _context.Events .Where(e => e.TrackingId == trackingId) .OrderBy(e => e.Timestamp) .ToListAsync(); return Ok(events); } /// /// Gets unique event types /// [HttpGet("types")] public async Task>> GetEventTypes() { return Ok(EnumSentinel.SelectableNames()); } /// /// Gets unique severities /// [HttpGet("severities")] public async Task>> GetSeverities() { return Ok(EnumSentinel.SelectableNames()); } [HttpGet("timeline")] public async Task> GetTimeline([FromQuery] int hours = 720) { hours = TimelineWindow.ClampHours(hours); DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset cutoff = now.AddHours(-hours); TimelineBucketSize size = TimelineBucketing.DefaultFor(hours); string bucketExpr = _databaseProvider.GetTimelineBucketExpr(size); string? schema = _databaseProvider.GetSchema(DbContextKind.Events); string eventsTable = schema is null ? "events" : $"{schema}.events"; List rows = await _context.Database .SqlQueryRaw( $$""" SELECT {{bucketExpr}} AS "bucket", event_type AS "event_type", COUNT(*) AS "count" FROM {{eventsTable}} WHERE "timestamp" >= {0} GROUP BY {{bucketExpr}}, event_type """, cutoff) .ToListAsync(); Dictionary<(DateTimeOffset Bucket, EventType Type), int> byBucketType = new(); HashSet presentSet = []; foreach (BucketTypeCount row in rows) { DateTimeOffset bucket = TimelineBucketing.ParseKey(row.Bucket, size); // Raw SQL skips the value converters, so mirror what they do with unknown text. EventType type = EnumSentinel.ParseOrUnknown(row.EventType); // Several unrecognised types read as one. // Their rows have to add up. byBucketType.TryGetValue((bucket, type), out int running); byBucketType[(bucket, type)] = running + row.Count; presentSet.Add(type); } List presentTypes = presentSet .OrderBy(t => (int)t) .ToList(); List buckets = []; foreach (DateTimeOffset bucket in TimelineBucketing.Buckets(cutoff, now, size)) { Dictionary counts = new(); foreach (EventType type in presentTypes) { if (byBucketType.TryGetValue((bucket, type), out int count) && count > 0) { counts[type.ToString()] = count; } } buckets.Add(new EventTypeTimelineBucket { Date = bucket, Counts = counts }); } return Ok(new EventTypeTimelineResponse { Types = presentTypes.Select(t => t.ToString()).ToList(), Buckets = buckets, }); } private sealed class BucketTypeCount { public string Bucket { get; set; } = string.Empty; public string EventType { get; set; } = string.Empty; public int Count { get; set; } } }