using System.ComponentModel; using System.Linq.Expressions; using ErsatzTV.Application.Logs; using ErsatzTV.Core.Api.Logs; using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] [RequiresAuthentication] public class LogsController(IMediator mediator) : ControllerBase { private const int MaxPageSize = 100; // Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on // Timestamp/Level; Message was never sortable there either). private static readonly System.Collections.Generic.HashSet AllowedSortFields = new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" }; [HttpGet("/api/v1/logs", Name = "GetLogs")] [Tags("Logs")] [EndpointSummary("Get recent log entries")] [EndpointDescription( "sortField is validated against an allow-list (timestamp, level); an unrecognized value " + "falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " + "pre-existing default, newest first).")] [EndpointGroupName("general")] [ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)] public async Task GetLogs( [FromQuery] [Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")] int pageNum = 0, [FromQuery] [Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")] int pageSize = 100, [FromQuery] string filter = "", [FromQuery] string sortField = "timestamp", [FromQuery] string sortDirection = "desc", CancellationToken cancellationToken = default) { int clampedPageNum = Math.Max(0, pageNum); int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty) ? sortField!.ToLowerInvariant() : "timestamp"; bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase); Expression> sortExpression = normalizedSortField switch { "level" => le => le.Level, _ => le => le.Timestamp }; PagedLogEntriesViewModel result = await mediator.Send( new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty) { SortExpression = sortExpression, SortDescending = descending }, cancellationToken); return new PagedLogEntriesResponseModel( result.TotalCount, result.Page.Map(ProjectToResponseModel).ToList()); } private static LogEntryResponseModel ProjectToResponseModel(LogEntryViewModel viewModel) => new(viewModel.Timestamp, viewModel.Level.ToString(), viewModel.Message); }