diff --git a/src/Managing.Api/Controllers/AdminController.cs b/src/Managing.Api/Controllers/AdminController.cs new file mode 100644 index 00000000..ac0daa11 --- /dev/null +++ b/src/Managing.Api/Controllers/AdminController.cs @@ -0,0 +1,232 @@ +using Managing.Api.Models.Responses; +using Managing.Application.Abstractions.Services; +using Managing.Application.Abstractions.Shared; +using Managing.Application.Shared; +using Managing.Domain.Backtests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using static Managing.Common.Enums; + +namespace Managing.Api.Controllers; + +/// +/// Controller for admin operations. +/// Provides endpoints for administrative tasks that require admin authorization. +/// All endpoints in this controller require admin access. +/// +[ApiController] +[Authorize] +[Route("[controller]")] +[Produces("application/json")] +public class AdminController : BaseController +{ + private readonly IBacktester _backtester; + private readonly IAdminConfigurationService _adminService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service for user management. + /// The service for backtesting operations. + /// The admin configuration service for authorization checks. + /// The logger instance. + public AdminController( + IUserService userService, + IBacktester backtester, + IAdminConfigurationService adminService, + ILogger logger) : base(userService) + { + _backtester = backtester; + _adminService = adminService; + _logger = logger; + } + + /// + /// Checks if the current user is an admin + /// + private async Task IsUserAdmin() + { + try + { + var user = await GetUser(); + if (user == null) + return false; + + return await _adminService.IsUserAdminAsync(user.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking if user is admin"); + return false; + } + } + + /// + /// Retrieves paginated bundle backtest requests for admin users. + /// This endpoint returns all bundle backtest requests without user filtering. + /// + /// Page number (defaults to 1) + /// Number of items per page (defaults to 50, max 100) + /// Field to sort by (defaults to "CreatedAt") + /// Sort order - "asc" or "desc" (defaults to "desc") + /// Filter by name contains + /// Filter by status (Pending, Running, Completed, Failed, Saved) + /// Filter by user ID + /// Filter by user name contains + /// Filter by minimum total backtests + /// Filter by maximum total backtests + /// Filter by minimum completed backtests + /// Filter by maximum completed backtests + /// Filter by minimum progress percentage (0-100) + /// Filter by maximum progress percentage (0-100) + /// Filter by created date from + /// Filter by created date to + /// A paginated list of bundle backtest requests. + [HttpGet] + [Route("BundleBacktestRequests/Paginated")] + public async Task> GetBundleBacktestRequestsPaginated( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] BundleBacktestRequestSortableColumn sortBy = BundleBacktestRequestSortableColumn.CreatedAt, + [FromQuery] string sortOrder = "desc", + [FromQuery] string? nameContains = null, + [FromQuery] BundleBacktestRequestStatus? status = null, + [FromQuery] int? userId = null, + [FromQuery] string? userNameContains = null, + [FromQuery] int? totalBacktestsMin = null, + [FromQuery] int? totalBacktestsMax = null, + [FromQuery] int? completedBacktestsMin = null, + [FromQuery] int? completedBacktestsMax = null, + [FromQuery] double? progressPercentageMin = null, + [FromQuery] double? progressPercentageMax = null, + [FromQuery] DateTime? createdAtFrom = null, + [FromQuery] DateTime? createdAtTo = null) + { + if (!await IsUserAdmin()) + { + _logger.LogWarning("Non-admin user attempted to access admin bundle backtest requests endpoint"); + return StatusCode(403, new { error = "Only admin users can access this endpoint" }); + } + + if (page < 1) + { + return BadRequest("Page must be greater than 0"); + } + + if (pageSize < 1 || pageSize > 100) + { + return BadRequest("Page size must be between 1 and 100"); + } + + if (sortOrder != "asc" && sortOrder != "desc") + { + return BadRequest("Sort order must be 'asc' or 'desc'"); + } + + // Validate progress percentage ranges [0,100] + if (progressPercentageMin.HasValue && (progressPercentageMin < 0 || progressPercentageMin > 100)) + { + return BadRequest("progressPercentageMin must be between 0 and 100"); + } + + if (progressPercentageMax.HasValue && (progressPercentageMax < 0 || progressPercentageMax > 100)) + { + return BadRequest("progressPercentageMax must be between 0 and 100"); + } + + if (progressPercentageMin.HasValue && progressPercentageMax.HasValue && progressPercentageMin > progressPercentageMax) + { + return BadRequest("progressPercentageMin must be less than or equal to progressPercentageMax"); + } + + // Build filter + var filter = new BundleBacktestRequestsFilter + { + NameContains = string.IsNullOrWhiteSpace(nameContains) ? null : nameContains.Trim(), + Status = status, + UserId = userId, + UserNameContains = string.IsNullOrWhiteSpace(userNameContains) ? null : userNameContains.Trim(), + TotalBacktestsMin = totalBacktestsMin, + TotalBacktestsMax = totalBacktestsMax, + CompletedBacktestsMin = completedBacktestsMin, + CompletedBacktestsMax = completedBacktestsMax, + ProgressPercentageMin = progressPercentageMin, + ProgressPercentageMax = progressPercentageMax, + CreatedAtFrom = createdAtFrom, + CreatedAtTo = createdAtTo + }; + + var (bundleRequests, totalCount) = + await _backtester.GetBundleBacktestRequestsPaginatedAsync( + page, + pageSize, + sortBy, + sortOrder, + filter); + + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + var response = new PaginatedBundleBacktestRequestsResponse + { + BundleRequests = bundleRequests.Select(b => new BundleBacktestRequestListItemResponse + { + RequestId = b.RequestId, + Name = b.Name, + Version = b.Version, + Status = b.Status.ToString(), + CreatedAt = b.CreatedAt, + CompletedAt = b.CompletedAt, + UpdatedAt = b.UpdatedAt, + TotalBacktests = b.TotalBacktests, + CompletedBacktests = b.CompletedBacktests, + FailedBacktests = b.FailedBacktests, + ProgressPercentage = b.ProgressPercentage, + UserId = b.User?.Id, + UserName = b.User?.Name, + ErrorMessage = b.ErrorMessage, + CurrentBacktest = b.CurrentBacktest, + EstimatedTimeRemainingSeconds = b.EstimatedTimeRemainingSeconds + }), + TotalCount = totalCount, + CurrentPage = page, + PageSize = pageSize, + TotalPages = totalPages, + HasNextPage = page < totalPages, + HasPreviousPage = page > 1 + }; + + return Ok(response); + } + + /// + /// Gets a summary of bundle backtest requests grouped by status with counts. + /// Admin only endpoint. + /// + /// Summary statistics of bundle backtest requests + [HttpGet] + [Route("BundleBacktestRequests/Summary")] + public async Task> GetBundleBacktestRequestsSummary() + { + if (!await IsUserAdmin()) + { + _logger.LogWarning("Non-admin user attempted to get bundle backtest requests summary"); + return StatusCode(403, new { error = "Only admin users can access bundle backtest requests summary" }); + } + + var summary = await _backtester.GetBundleBacktestRequestsSummaryAsync(); + + var response = new BundleBacktestRequestSummaryResponse + { + StatusSummary = summary.StatusCounts.Select(s => new BundleBacktestRequestStatusSummary + { + Status = s.Status.ToString(), + Count = s.Count + }).ToList(), + TotalRequests = summary.TotalRequests + }; + + return Ok(response); + } +} + diff --git a/src/Managing.Api/Models/Responses/BundleBacktestRequestSummaryResponse.cs b/src/Managing.Api/Models/Responses/BundleBacktestRequestSummaryResponse.cs new file mode 100644 index 00000000..f6003ba6 --- /dev/null +++ b/src/Managing.Api/Models/Responses/BundleBacktestRequestSummaryResponse.cs @@ -0,0 +1,35 @@ +#nullable enable +namespace Managing.Api.Models.Responses; + +/// +/// Response model for bundle backtest request summary statistics +/// +public class BundleBacktestRequestSummaryResponse +{ + /// + /// Summary of bundle requests by status + /// + public List StatusSummary { get; set; } = new(); + + /// + /// Total number of bundle backtest requests + /// + public int TotalRequests { get; set; } +} + +/// +/// Summary of bundle backtest requests by status +/// +public class BundleBacktestRequestStatusSummary +{ + /// + /// The status name + /// + public string Status { get; set; } = string.Empty; + + /// + /// The count of bundle requests with this status + /// + public int Count { get; set; } +} + diff --git a/src/Managing.Api/Models/Responses/PaginatedBundleBacktestRequestsResponse.cs b/src/Managing.Api/Models/Responses/PaginatedBundleBacktestRequestsResponse.cs new file mode 100644 index 00000000..8ff71812 --- /dev/null +++ b/src/Managing.Api/Models/Responses/PaginatedBundleBacktestRequestsResponse.cs @@ -0,0 +1,67 @@ +#nullable enable +namespace Managing.Api.Models.Responses; + +/// +/// Response model for paginated bundle backtest requests +/// +public class PaginatedBundleBacktestRequestsResponse +{ + /// + /// The list of bundle backtest requests for the current page + /// + public IEnumerable BundleRequests { get; set; } = new List(); + + /// + /// Total number of bundle backtest requests across all pages + /// + public int TotalCount { get; set; } + + /// + /// Current page number + /// + public int CurrentPage { get; set; } + + /// + /// Number of items per page + /// + public int PageSize { get; set; } + + /// + /// Total number of pages + /// + public int TotalPages { get; set; } + + /// + /// Whether there are more pages available + /// + public bool HasNextPage { get; set; } + + /// + /// Whether there are previous pages available + /// + public bool HasPreviousPage { get; set; } +} + +/// +/// Response model for a bundle backtest request list item (summary view) +/// +public class BundleBacktestRequestListItemResponse +{ + public Guid RequestId { get; set; } + public string Name { get; set; } = string.Empty; + public int Version { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public int TotalBacktests { get; set; } + public int CompletedBacktests { get; set; } + public int FailedBacktests { get; set; } + public double ProgressPercentage { get; set; } + public int? UserId { get; set; } + public string? UserName { get; set; } + public string? ErrorMessage { get; set; } + public string? CurrentBacktest { get; set; } + public int? EstimatedTimeRemainingSeconds { get; set; } +} + diff --git a/src/Managing.Application.Abstractions/Repositories/BundleBacktestRequestSummary.cs b/src/Managing.Application.Abstractions/Repositories/BundleBacktestRequestSummary.cs new file mode 100644 index 00000000..7b94a9bc --- /dev/null +++ b/src/Managing.Application.Abstractions/Repositories/BundleBacktestRequestSummary.cs @@ -0,0 +1,36 @@ +using Managing.Domain.Backtests; + +namespace Managing.Application.Abstractions.Repositories; + +/// +/// Summary statistics for bundle backtest requests +/// +public class BundleBacktestRequestSummary +{ + /// + /// Counts of bundle requests by status + /// + public List StatusCounts { get; set; } = new(); + + /// + /// Total number of bundle backtest requests + /// + public int TotalRequests { get; set; } +} + +/// +/// Count of bundle backtest requests by status +/// +public class BundleBacktestRequestStatusCount +{ + /// + /// The status + /// + public BundleBacktestRequestStatus Status { get; set; } + + /// + /// The count of bundle requests with this status + /// + public int Count { get; set; } +} + diff --git a/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs b/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs index 4111ad58..0b0747ff 100644 --- a/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs +++ b/src/Managing.Application.Abstractions/Repositories/IBacktestRepository.cs @@ -57,4 +57,22 @@ public interface IBacktestRepository Task DeleteBundleBacktestRequestByIdForUserAsync(User user, Guid id); IEnumerable GetBundleBacktestRequestsByStatus(BundleBacktestRequestStatus status); Task> GetBundleBacktestRequestsByStatusAsync(BundleBacktestRequestStatus status); + + // Admin methods - no user filter + (IEnumerable BundleRequests, int TotalCount) GetBundleBacktestRequestsPaginated( + int page, + int pageSize, + Enums.BundleBacktestRequestSortableColumn sortBy = Enums.BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null); + + Task<(IEnumerable BundleRequests, int TotalCount)> GetBundleBacktestRequestsPaginatedAsync( + int page, + int pageSize, + Enums.BundleBacktestRequestSortableColumn sortBy = Enums.BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null); + + // Admin summary methods + Task GetBundleBacktestRequestsSummaryAsync(); } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Services/IBacktester.cs b/src/Managing.Application.Abstractions/Services/IBacktester.cs index 71479ebe..11df57a5 100644 --- a/src/Managing.Application.Abstractions/Services/IBacktester.cs +++ b/src/Managing.Application.Abstractions/Services/IBacktester.cs @@ -1,4 +1,5 @@ -using Managing.Application.Abstractions.Shared; +using Managing.Application.Abstractions.Repositories; +using Managing.Application.Abstractions.Shared; using Managing.Common; using Managing.Domain.Backtests; using Managing.Domain.Bots; @@ -98,6 +99,21 @@ namespace Managing.Application.Abstractions.Services IEnumerable GetBundleBacktestRequestsByStatus(BundleBacktestRequestStatus status); Task> GetBundleBacktestRequestsByStatusAsync(BundleBacktestRequestStatus status); + // Admin methods - no user filter + (IEnumerable BundleRequests, int TotalCount) GetBundleBacktestRequestsPaginated( + int page, + int pageSize, + Enums.BundleBacktestRequestSortableColumn sortBy = Enums.BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null); + Task<(IEnumerable BundleRequests, int TotalCount)> GetBundleBacktestRequestsPaginatedAsync( + int page, + int pageSize, + Enums.BundleBacktestRequestSortableColumn sortBy = Enums.BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null); + + Task GetBundleBacktestRequestsSummaryAsync(); } } \ No newline at end of file diff --git a/src/Managing.Application.Abstractions/Shared/BundleBacktestRequestsFilter.cs b/src/Managing.Application.Abstractions/Shared/BundleBacktestRequestsFilter.cs new file mode 100644 index 00000000..beee6bf0 --- /dev/null +++ b/src/Managing.Application.Abstractions/Shared/BundleBacktestRequestsFilter.cs @@ -0,0 +1,70 @@ +using Managing.Domain.Backtests; + +namespace Managing.Application.Abstractions.Shared; + +/// +/// Filter model for bundle backtest requests +/// +public class BundleBacktestRequestsFilter +{ + /// + /// Filter by name contains (case-insensitive) + /// + public string? NameContains { get; set; } + + /// + /// Filter by status + /// + public BundleBacktestRequestStatus? Status { get; set; } + + /// + /// Filter by user ID + /// + public int? UserId { get; set; } + + /// + /// Filter by user name contains (case-insensitive) + /// + public string? UserNameContains { get; set; } + + /// + /// Filter by minimum total backtests + /// + public int? TotalBacktestsMin { get; set; } + + /// + /// Filter by maximum total backtests + /// + public int? TotalBacktestsMax { get; set; } + + /// + /// Filter by minimum completed backtests + /// + public int? CompletedBacktestsMin { get; set; } + + /// + /// Filter by maximum completed backtests + /// + public int? CompletedBacktestsMax { get; set; } + + /// + /// Filter by minimum progress percentage (0-100) + /// + public double? ProgressPercentageMin { get; set; } + + /// + /// Filter by maximum progress percentage (0-100) + /// + public double? ProgressPercentageMax { get; set; } + + /// + /// Filter by created date from + /// + public DateTime? CreatedAtFrom { get; set; } + + /// + /// Filter by created date to + /// + public DateTime? CreatedAtTo { get; set; } +} + diff --git a/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs b/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs index e3ad0051..71ba3950 100644 --- a/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs +++ b/src/Managing.Application/Backtests/BacktestExecutorAdapter.cs @@ -1,3 +1,4 @@ +using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Shared; using Managing.Domain.Backtests; @@ -91,34 +92,109 @@ public class BacktestExecutorAdapter : IBacktester } // Methods not needed for compute worker - throw NotImplementedException - public Task DeleteBacktestAsync(string id) => throw new NotImplementedException("Not available in compute worker"); - public bool DeleteBacktests() => throw new NotImplementedException("Not available in compute worker"); - public IEnumerable GetBacktestsByUser(User user) => throw new NotImplementedException("Not available in compute worker"); - public Task> GetBacktestsByUserAsync(User user) => throw new NotImplementedException("Not available in compute worker"); - public IEnumerable GetBacktestsByRequestId(Guid requestId) => throw new NotImplementedException("Not available in compute worker"); - public Task> GetBacktestsByRequestIdAsync(Guid requestId) => throw new NotImplementedException("Not available in compute worker"); - public (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(Guid requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc") => throw new NotImplementedException("Not available in compute worker"); - public Task<(IEnumerable Backtests, int TotalCount)> GetBacktestsByRequestIdPaginatedAsync(Guid requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc") => throw new NotImplementedException("Not available in compute worker"); - public Task GetBacktestByIdForUserAsync(User user, string id) => throw new NotImplementedException("Not available in compute worker"); - public Task DeleteBacktestByUserAsync(User user, string id) => throw new NotImplementedException("Not available in compute worker"); - public Task DeleteBacktestsByIdsForUserAsync(User user, IEnumerable ids) => throw new NotImplementedException("Not available in compute worker"); - public bool DeleteBacktestsByUser(User user) => throw new NotImplementedException("Not available in compute worker"); - public (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, int pageSize, BacktestSortableColumn sortBy, string sortOrder = "desc", BacktestsFilter? filter = null) => throw new NotImplementedException("Not available in compute worker"); - public Task<(IEnumerable Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(User user, int page, int pageSize, BacktestSortableColumn sortBy, string sortOrder = "desc", BacktestsFilter? filter = null) => throw new NotImplementedException("Not available in compute worker"); - public Task DeleteBacktestsByRequestIdAsync(Guid requestId) => throw new NotImplementedException("Not available in compute worker"); - public Task DeleteBacktestsByFiltersAsync(User user, BacktestsFilter filter) => throw new NotImplementedException("Not available in compute worker"); - public Task InsertBundleBacktestRequestForUserAsync(User user, BundleBacktestRequest bundleRequest, bool saveAsTemplate = false) => throw new NotImplementedException("Not available in compute worker"); - public Task SaveBundleBacktestRequestAsync(User user, BundleBacktestRequest bundleRequest) => throw new NotImplementedException("Not available in compute worker"); - public Task CreateJobsForBundleRequestAsync(BundleBacktestRequest bundleRequest) => throw new NotImplementedException("Not available in compute worker"); - public IEnumerable GetBundleBacktestRequestsByUser(User user) => throw new NotImplementedException("Not available in compute worker"); - public Task> GetBundleBacktestRequestsByUserAsync(User user) => throw new NotImplementedException("Not available in compute worker"); - public BundleBacktestRequest? GetBundleBacktestRequestByIdForUser(User user, Guid id) => throw new NotImplementedException("Not available in compute worker"); - public Task GetBundleBacktestRequestByIdForUserAsync(User user, Guid id) => throw new NotImplementedException("Not available in compute worker"); - public void UpdateBundleBacktestRequest(BundleBacktestRequest bundleRequest) => throw new NotImplementedException("Not available in compute worker"); - public Task UpdateBundleBacktestRequestAsync(BundleBacktestRequest bundleRequest) => throw new NotImplementedException("Not available in compute worker"); - public void DeleteBundleBacktestRequestByIdForUser(User user, Guid id) => throw new NotImplementedException("Not available in compute worker"); - public Task DeleteBundleBacktestRequestByIdForUserAsync(User user, Guid id) => throw new NotImplementedException("Not available in compute worker"); - public IEnumerable GetBundleBacktestRequestsByStatus(BundleBacktestRequestStatus status) => throw new NotImplementedException("Not available in compute worker"); - public Task> GetBundleBacktestRequestsByStatusAsync(BundleBacktestRequestStatus status) => throw new NotImplementedException("Not available in compute worker"); -} + public Task DeleteBacktestAsync(string id) => + throw new NotImplementedException("Not available in compute worker"); + public bool DeleteBacktests() => throw new NotImplementedException("Not available in compute worker"); + + public IEnumerable GetBacktestsByUser(User user) => + throw new NotImplementedException("Not available in compute worker"); + + public Task> GetBacktestsByUserAsync(User user) => + throw new NotImplementedException("Not available in compute worker"); + + public IEnumerable GetBacktestsByRequestId(Guid requestId) => + throw new NotImplementedException("Not available in compute worker"); + + public Task> GetBacktestsByRequestIdAsync(Guid requestId) => + throw new NotImplementedException("Not available in compute worker"); + + public (IEnumerable Backtests, int TotalCount) GetBacktestsByRequestIdPaginated(Guid requestId, + int page, int pageSize, string sortBy = "score", string sortOrder = "desc") => + throw new NotImplementedException("Not available in compute worker"); + + public Task<(IEnumerable Backtests, int TotalCount)> GetBacktestsByRequestIdPaginatedAsync( + Guid requestId, int page, int pageSize, string sortBy = "score", string sortOrder = "desc") => + throw new NotImplementedException("Not available in compute worker"); + + public Task GetBacktestByIdForUserAsync(User user, string id) => + throw new NotImplementedException("Not available in compute worker"); + + public Task DeleteBacktestByUserAsync(User user, string id) => + throw new NotImplementedException("Not available in compute worker"); + + public Task DeleteBacktestsByIdsForUserAsync(User user, IEnumerable ids) => + throw new NotImplementedException("Not available in compute worker"); + + public bool DeleteBacktestsByUser(User user) => + throw new NotImplementedException("Not available in compute worker"); + + public (IEnumerable Backtests, int TotalCount) GetBacktestsByUserPaginated(User user, int page, + int pageSize, BacktestSortableColumn sortBy, string sortOrder = "desc", BacktestsFilter? filter = null) => + throw new NotImplementedException("Not available in compute worker"); + + public Task<(IEnumerable Backtests, int TotalCount)> GetBacktestsByUserPaginatedAsync(User user, + int page, int pageSize, BacktestSortableColumn sortBy, string sortOrder = "desc", + BacktestsFilter? filter = null) => throw new NotImplementedException("Not available in compute worker"); + + public Task DeleteBacktestsByRequestIdAsync(Guid requestId) => + throw new NotImplementedException("Not available in compute worker"); + + public Task DeleteBacktestsByFiltersAsync(User user, BacktestsFilter filter) => + throw new NotImplementedException("Not available in compute worker"); + + public Task InsertBundleBacktestRequestForUserAsync(User user, BundleBacktestRequest bundleRequest, + bool saveAsTemplate = false) => throw new NotImplementedException("Not available in compute worker"); + + public Task SaveBundleBacktestRequestAsync(User user, BundleBacktestRequest bundleRequest) => + throw new NotImplementedException("Not available in compute worker"); + + public Task CreateJobsForBundleRequestAsync(BundleBacktestRequest bundleRequest) => + throw new NotImplementedException("Not available in compute worker"); + + public IEnumerable GetBundleBacktestRequestsByUser(User user) => + throw new NotImplementedException("Not available in compute worker"); + + public Task> GetBundleBacktestRequestsByUserAsync(User user) => + throw new NotImplementedException("Not available in compute worker"); + + public BundleBacktestRequest? GetBundleBacktestRequestByIdForUser(User user, Guid id) => + throw new NotImplementedException("Not available in compute worker"); + + public Task GetBundleBacktestRequestByIdForUserAsync(User user, Guid id) => + throw new NotImplementedException("Not available in compute worker"); + + public void UpdateBundleBacktestRequest(BundleBacktestRequest bundleRequest) => + throw new NotImplementedException("Not available in compute worker"); + + public Task UpdateBundleBacktestRequestAsync(BundleBacktestRequest bundleRequest) => + throw new NotImplementedException("Not available in compute worker"); + + public void DeleteBundleBacktestRequestByIdForUser(User user, Guid id) => + throw new NotImplementedException("Not available in compute worker"); + + public Task DeleteBundleBacktestRequestByIdForUserAsync(User user, Guid id) => + throw new NotImplementedException("Not available in compute worker"); + + public IEnumerable GetBundleBacktestRequestsByStatus(BundleBacktestRequestStatus status) => + throw new NotImplementedException("Not available in compute worker"); + + public Task> + GetBundleBacktestRequestsByStatusAsync(BundleBacktestRequestStatus status) => + throw new NotImplementedException("Not available in compute worker"); + + public (IEnumerable BundleRequests, int TotalCount) GetBundleBacktestRequestsPaginated( + int page, int pageSize, + BundleBacktestRequestSortableColumn sortBy = BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", BundleBacktestRequestsFilter? filter = null) => + throw new NotImplementedException("Not available in compute worker"); + + public Task<(IEnumerable BundleRequests, int TotalCount)> + GetBundleBacktestRequestsPaginatedAsync(int page, int pageSize, + BundleBacktestRequestSortableColumn sortBy = BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", BundleBacktestRequestsFilter? filter = null) => + throw new NotImplementedException("Not available in compute worker"); + + public Task GetBundleBacktestRequestsSummaryAsync() => + throw new NotImplementedException("Not available in compute worker"); +} \ No newline at end of file diff --git a/src/Managing.Application/Backtests/Backtester.cs b/src/Managing.Application/Backtests/Backtester.cs index 452e3b1a..ac9462f0 100644 --- a/src/Managing.Application/Backtests/Backtester.cs +++ b/src/Managing.Application/Backtests/Backtester.cs @@ -561,6 +561,35 @@ namespace Managing.Application.Backtests return await _backtestRepository.GetBundleBacktestRequestsByStatusAsync(status); } + public (IEnumerable BundleRequests, int TotalCount) GetBundleBacktestRequestsPaginated( + int page, + int pageSize, + BundleBacktestRequestSortableColumn sortBy = BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null) + { + var (bundleRequests, totalCount) = + _backtestRepository.GetBundleBacktestRequestsPaginated(page, pageSize, sortBy, sortOrder, filter); + return (bundleRequests, totalCount); + } + + public async Task<(IEnumerable BundleRequests, int TotalCount)> GetBundleBacktestRequestsPaginatedAsync( + int page, + int pageSize, + BundleBacktestRequestSortableColumn sortBy = BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null) + { + var (bundleRequests, totalCount) = + await _backtestRepository.GetBundleBacktestRequestsPaginatedAsync(page, pageSize, sortBy, sortOrder, filter); + return (bundleRequests, totalCount); + } + + public async Task GetBundleBacktestRequestsSummaryAsync() + { + return await _backtestRepository.GetBundleBacktestRequestsSummaryAsync(); + } + /// /// Sends a LightBacktestResponse to all SignalR subscribers of a bundle request. /// diff --git a/src/Managing.Common/Enums.cs b/src/Managing.Common/Enums.cs index 0932e2ae..57c0ec8e 100644 --- a/src/Managing.Common/Enums.cs +++ b/src/Managing.Common/Enums.cs @@ -524,6 +524,25 @@ public static class Enums Name } + /// + /// Sortable columns for bundle backtest requests pagination endpoints + /// + public enum BundleBacktestRequestSortableColumn + { + RequestId, + Name, + Status, + CreatedAt, + CompletedAt, + TotalBacktests, + CompletedBacktests, + FailedBacktests, + ProgressPercentage, + UserId, + UserName, + UpdatedAt + } + /// /// Event types for agent summary updates /// diff --git a/src/Managing.Domain/Backtests/BundleBacktestRequest.cs b/src/Managing.Domain/Backtests/BundleBacktestRequest.cs index b0065079..1a1b0605 100644 --- a/src/Managing.Domain/Backtests/BundleBacktestRequest.cs +++ b/src/Managing.Domain/Backtests/BundleBacktestRequest.cs @@ -13,6 +13,7 @@ public class BundleBacktestRequest { RequestId = Guid.NewGuid(); CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; Status = BundleBacktestRequestStatus.Pending; Results = new List(); UniversalConfigJson = string.Empty; @@ -29,6 +30,7 @@ public class BundleBacktestRequest { RequestId = requestId; CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; Status = BundleBacktestRequestStatus.Pending; Results = new List(); UniversalConfigJson = string.Empty; @@ -149,6 +151,12 @@ public class BundleBacktestRequest /// Estimated time remaining in seconds /// public int? EstimatedTimeRemainingSeconds { get; set; } + + /// + /// When the request was last updated + /// + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } /// diff --git a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs index dda6b364..159ef85d 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlBacktestRepository.cs @@ -948,4 +948,357 @@ public class PostgreSqlBacktestRepository : IBacktestRepository return entities.Select(PostgreSqlMappers.Map); } + + public (IEnumerable BundleRequests, int TotalCount) GetBundleBacktestRequestsPaginated( + int page, + int pageSize, + BundleBacktestRequestSortableColumn sortBy = BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null) + { + var baseQuery = _context.BundleBacktestRequests + .AsNoTracking() + .Include(b => b.User) + .AsQueryable(); + + // Apply filters + if (filter != null) + { + if (!string.IsNullOrWhiteSpace(filter.NameContains)) + { + var nameLike = $"%{filter.NameContains.Trim()}%"; + baseQuery = baseQuery.Where(b => EF.Functions.ILike(b.Name, nameLike)); + } + + if (filter.Status.HasValue) + { + baseQuery = baseQuery.Where(b => b.Status == filter.Status.Value); + } + + if (filter.UserId.HasValue) + { + baseQuery = baseQuery.Where(b => b.UserId == filter.UserId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.UserNameContains)) + { + var userNameLike = $"%{filter.UserNameContains.Trim()}%"; + baseQuery = baseQuery.Where(b => b.User != null && EF.Functions.ILike(b.User.Name, userNameLike)); + } + + if (filter.TotalBacktestsMin.HasValue) + { + baseQuery = baseQuery.Where(b => b.TotalBacktests >= filter.TotalBacktestsMin.Value); + } + + if (filter.TotalBacktestsMax.HasValue) + { + baseQuery = baseQuery.Where(b => b.TotalBacktests <= filter.TotalBacktestsMax.Value); + } + + if (filter.CompletedBacktestsMin.HasValue) + { + baseQuery = baseQuery.Where(b => b.CompletedBacktests >= filter.CompletedBacktestsMin.Value); + } + + if (filter.CompletedBacktestsMax.HasValue) + { + baseQuery = baseQuery.Where(b => b.CompletedBacktests <= filter.CompletedBacktestsMax.Value); + } + + if (filter.ProgressPercentageMin.HasValue) + { + var minProgress = filter.ProgressPercentageMin.Value; + baseQuery = baseQuery.Where(b => b.TotalBacktests > 0 && + (double)b.CompletedBacktests / b.TotalBacktests * 100 >= minProgress); + } + + if (filter.ProgressPercentageMax.HasValue) + { + var maxProgress = filter.ProgressPercentageMax.Value; + baseQuery = baseQuery.Where(b => b.TotalBacktests > 0 && + (double)b.CompletedBacktests / b.TotalBacktests * 100 <= maxProgress); + } + + if (filter.CreatedAtFrom.HasValue) + { + baseQuery = baseQuery.Where(b => b.CreatedAt >= filter.CreatedAtFrom.Value); + } + + if (filter.CreatedAtTo.HasValue) + { + baseQuery = baseQuery.Where(b => b.CreatedAt <= filter.CreatedAtTo.Value); + } + } + + var totalCount = baseQuery.Count(); + + // Apply sorting + IQueryable sortedQuery = sortBy switch + { + BundleBacktestRequestSortableColumn.RequestId => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.RequestId) + : baseQuery.OrderBy(b => b.RequestId), + BundleBacktestRequestSortableColumn.Name => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.Name) + : baseQuery.OrderBy(b => b.Name), + BundleBacktestRequestSortableColumn.Status => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.Status) + : baseQuery.OrderBy(b => b.Status), + BundleBacktestRequestSortableColumn.CreatedAt => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CreatedAt) + : baseQuery.OrderBy(b => b.CreatedAt), + BundleBacktestRequestSortableColumn.CompletedAt => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CompletedAt ?? DateTime.MinValue) + : baseQuery.OrderBy(b => b.CompletedAt ?? DateTime.MaxValue), + BundleBacktestRequestSortableColumn.TotalBacktests => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.TotalBacktests) + : baseQuery.OrderBy(b => b.TotalBacktests), + BundleBacktestRequestSortableColumn.CompletedBacktests => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CompletedBacktests) + : baseQuery.OrderBy(b => b.CompletedBacktests), + BundleBacktestRequestSortableColumn.FailedBacktests => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.FailedBacktests) + : baseQuery.OrderBy(b => b.FailedBacktests), + BundleBacktestRequestSortableColumn.ProgressPercentage => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.TotalBacktests > 0 ? (double)b.CompletedBacktests / b.TotalBacktests : 0) + : baseQuery.OrderBy(b => b.TotalBacktests > 0 ? (double)b.CompletedBacktests / b.TotalBacktests : 0), + BundleBacktestRequestSortableColumn.UserId => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.UserId ?? int.MaxValue) + : baseQuery.OrderBy(b => b.UserId ?? int.MinValue), + BundleBacktestRequestSortableColumn.UserName => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.User != null ? b.User.Name : string.Empty) + : baseQuery.OrderBy(b => b.User != null ? b.User.Name : string.Empty), + BundleBacktestRequestSortableColumn.UpdatedAt => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.UpdatedAt) + : baseQuery.OrderBy(b => b.UpdatedAt), + _ => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CreatedAt) + : baseQuery.OrderBy(b => b.CreatedAt) + }; + + var entities = sortedQuery + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + + var mappedRequests = entities.Select(PostgreSqlMappers.Map); + + return (mappedRequests, totalCount); + } + + public async Task<(IEnumerable BundleRequests, int TotalCount)> GetBundleBacktestRequestsPaginatedAsync( + int page, + int pageSize, + BundleBacktestRequestSortableColumn sortBy = BundleBacktestRequestSortableColumn.CreatedAt, + string sortOrder = "desc", + BundleBacktestRequestsFilter? filter = null) + { + var baseQuery = _context.BundleBacktestRequests + .AsNoTracking() + .Include(b => b.User) + .AsQueryable(); + + // Apply filters + if (filter != null) + { + if (!string.IsNullOrWhiteSpace(filter.NameContains)) + { + var nameLike = $"%{filter.NameContains.Trim()}%"; + baseQuery = baseQuery.Where(b => EF.Functions.ILike(b.Name, nameLike)); + } + + if (filter.Status.HasValue) + { + baseQuery = baseQuery.Where(b => b.Status == filter.Status.Value); + } + + if (filter.UserId.HasValue) + { + baseQuery = baseQuery.Where(b => b.UserId == filter.UserId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.UserNameContains)) + { + var userNameLike = $"%{filter.UserNameContains.Trim()}%"; + baseQuery = baseQuery.Where(b => b.User != null && EF.Functions.ILike(b.User.Name, userNameLike)); + } + + if (filter.TotalBacktestsMin.HasValue) + { + baseQuery = baseQuery.Where(b => b.TotalBacktests >= filter.TotalBacktestsMin.Value); + } + + if (filter.TotalBacktestsMax.HasValue) + { + baseQuery = baseQuery.Where(b => b.TotalBacktests <= filter.TotalBacktestsMax.Value); + } + + if (filter.CompletedBacktestsMin.HasValue) + { + baseQuery = baseQuery.Where(b => b.CompletedBacktests >= filter.CompletedBacktestsMin.Value); + } + + if (filter.CompletedBacktestsMax.HasValue) + { + baseQuery = baseQuery.Where(b => b.CompletedBacktests <= filter.CompletedBacktestsMax.Value); + } + + if (filter.ProgressPercentageMin.HasValue) + { + var minProgress = filter.ProgressPercentageMin.Value; + baseQuery = baseQuery.Where(b => b.TotalBacktests > 0 && + (double)b.CompletedBacktests / b.TotalBacktests * 100 >= minProgress); + } + + if (filter.ProgressPercentageMax.HasValue) + { + var maxProgress = filter.ProgressPercentageMax.Value; + baseQuery = baseQuery.Where(b => b.TotalBacktests > 0 && + (double)b.CompletedBacktests / b.TotalBacktests * 100 <= maxProgress); + } + + if (filter.CreatedAtFrom.HasValue) + { + baseQuery = baseQuery.Where(b => b.CreatedAt >= filter.CreatedAtFrom.Value); + } + + if (filter.CreatedAtTo.HasValue) + { + baseQuery = baseQuery.Where(b => b.CreatedAt <= filter.CreatedAtTo.Value); + } + } + + var totalCount = await baseQuery.CountAsync().ConfigureAwait(false); + + // Apply sorting + IQueryable sortedQuery = sortBy switch + { + BundleBacktestRequestSortableColumn.RequestId => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.RequestId) + : baseQuery.OrderBy(b => b.RequestId), + BundleBacktestRequestSortableColumn.Name => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.Name) + : baseQuery.OrderBy(b => b.Name), + BundleBacktestRequestSortableColumn.Status => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.Status) + : baseQuery.OrderBy(b => b.Status), + BundleBacktestRequestSortableColumn.CreatedAt => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CreatedAt) + : baseQuery.OrderBy(b => b.CreatedAt), + BundleBacktestRequestSortableColumn.CompletedAt => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CompletedAt ?? DateTime.MinValue) + : baseQuery.OrderBy(b => b.CompletedAt ?? DateTime.MaxValue), + BundleBacktestRequestSortableColumn.TotalBacktests => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.TotalBacktests) + : baseQuery.OrderBy(b => b.TotalBacktests), + BundleBacktestRequestSortableColumn.CompletedBacktests => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CompletedBacktests) + : baseQuery.OrderBy(b => b.CompletedBacktests), + BundleBacktestRequestSortableColumn.FailedBacktests => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.FailedBacktests) + : baseQuery.OrderBy(b => b.FailedBacktests), + BundleBacktestRequestSortableColumn.ProgressPercentage => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.TotalBacktests > 0 ? (double)b.CompletedBacktests / b.TotalBacktests : 0) + : baseQuery.OrderBy(b => b.TotalBacktests > 0 ? (double)b.CompletedBacktests / b.TotalBacktests : 0), + BundleBacktestRequestSortableColumn.UserId => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.UserId ?? int.MaxValue) + : baseQuery.OrderBy(b => b.UserId ?? int.MinValue), + BundleBacktestRequestSortableColumn.UserName => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.User != null ? b.User.Name : string.Empty) + : baseQuery.OrderBy(b => b.User != null ? b.User.Name : string.Empty), + BundleBacktestRequestSortableColumn.UpdatedAt => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.UpdatedAt) + : baseQuery.OrderBy(b => b.UpdatedAt), + _ => sortOrder == "desc" + ? baseQuery.OrderByDescending(b => b.CreatedAt) + : baseQuery.OrderBy(b => b.CreatedAt) + }; + + var entities = await sortedQuery + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync() + .ConfigureAwait(false); + + var mappedRequests = entities.Select(PostgreSqlMappers.Map); + + return (mappedRequests, totalCount); + } + + public async Task GetBundleBacktestRequestsSummaryAsync() + { + // Use ADO.NET directly for aggregation queries to avoid EF Core mapping issues + var connection = _context.Database.GetDbConnection(); + await connection.OpenAsync(); + + try + { + var statusCounts = new List(); + var totalRequests = 0; + + // Query 1: Status summary + // Note: Status is stored as text in PostgreSQL, not as integer + var statusSummarySql = @" + SELECT ""Status"", COUNT(*) as Count + FROM ""BundleBacktestRequests"" + GROUP BY ""Status"" + ORDER BY ""Status"""; + + using (var command = connection.CreateCommand()) + { + command.CommandText = statusSummarySql; + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + var statusString = reader.GetString(0); + var count = reader.GetInt32(1); + + // Parse the string status to enum + if (Enum.TryParse(statusString, ignoreCase: true, out var status)) + { + statusCounts.Add(new BundleBacktestRequestStatusCountResult + { + Status = status, + Count = count + }); + } + } + } + } + + // Query 2: Total count + var totalCountSql = @" + SELECT COUNT(*) as Count + FROM ""BundleBacktestRequests"""; + + using (var command = connection.CreateCommand()) + { + command.CommandText = totalCountSql; + var result = await command.ExecuteScalarAsync(); + totalRequests = result != null ? Convert.ToInt32(result) : 0; + } + + return new BundleBacktestRequestSummary + { + StatusCounts = statusCounts.Select(s => new BundleBacktestRequestStatusCount + { + Status = s.Status, + Count = s.Count + }).ToList(), + TotalRequests = totalRequests + }; + } + finally + { + await connection.CloseAsync(); + } + } + + private class BundleBacktestRequestStatusCountResult + { + public BundleBacktestRequestStatus Status { get; set; } + public int Count { get; set; } + } } \ No newline at end of file diff --git a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs index ff5c40de..18561bf5 100644 --- a/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs +++ b/src/Managing.Infrastructure.Database/PostgreSql/PostgreSqlMappers.cs @@ -382,6 +382,7 @@ public static class PostgreSqlMappers User = entity.User != null ? Map(entity.User) : null, CreatedAt = entity.CreatedAt, CompletedAt = entity.CompletedAt, + UpdatedAt = entity.UpdatedAt, Status = entity.Status, UniversalConfigJson = entity.UniversalConfigJson, DateTimeRangesJson = entity.DateTimeRangesJson, diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index 19cd16b9..cec03243 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -368,6 +368,126 @@ export class AccountClient extends AuthorizedApiBase { } } +export class AdminClient extends AuthorizedApiBase { + private http: { fetch(url: RequestInfo, init?: RequestInit): Promise }; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(configuration: IConfig, baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) { + super(configuration); + this.http = http ? http : window as any; + this.baseUrl = baseUrl ?? "http://localhost:5000"; + } + + admin_GetBundleBacktestRequestsPaginated(page: number | undefined, pageSize: number | undefined, sortBy: BundleBacktestRequestSortableColumn | undefined, sortOrder: string | null | undefined, nameContains: string | null | undefined, status: BundleBacktestRequestStatus | null | undefined, userId: number | null | undefined, userNameContains: string | null | undefined, totalBacktestsMin: number | null | undefined, totalBacktestsMax: number | null | undefined, completedBacktestsMin: number | null | undefined, completedBacktestsMax: number | null | undefined, progressPercentageMin: number | null | undefined, progressPercentageMax: number | null | undefined, createdAtFrom: Date | null | undefined, createdAtTo: Date | null | undefined): Promise { + let url_ = this.baseUrl + "/Admin/BundleBacktestRequests/Paginated?"; + if (page === null) + throw new Error("The parameter 'page' cannot be null."); + else if (page !== undefined) + url_ += "page=" + encodeURIComponent("" + page) + "&"; + if (pageSize === null) + throw new Error("The parameter 'pageSize' cannot be null."); + else if (pageSize !== undefined) + url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&"; + if (sortBy === null) + throw new Error("The parameter 'sortBy' cannot be null."); + else if (sortBy !== undefined) + url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&"; + if (sortOrder !== undefined && sortOrder !== null) + url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&"; + if (nameContains !== undefined && nameContains !== null) + url_ += "nameContains=" + encodeURIComponent("" + nameContains) + "&"; + if (status !== undefined && status !== null) + url_ += "status=" + encodeURIComponent("" + status) + "&"; + if (userId !== undefined && userId !== null) + url_ += "userId=" + encodeURIComponent("" + userId) + "&"; + if (userNameContains !== undefined && userNameContains !== null) + url_ += "userNameContains=" + encodeURIComponent("" + userNameContains) + "&"; + if (totalBacktestsMin !== undefined && totalBacktestsMin !== null) + url_ += "totalBacktestsMin=" + encodeURIComponent("" + totalBacktestsMin) + "&"; + if (totalBacktestsMax !== undefined && totalBacktestsMax !== null) + url_ += "totalBacktestsMax=" + encodeURIComponent("" + totalBacktestsMax) + "&"; + if (completedBacktestsMin !== undefined && completedBacktestsMin !== null) + url_ += "completedBacktestsMin=" + encodeURIComponent("" + completedBacktestsMin) + "&"; + if (completedBacktestsMax !== undefined && completedBacktestsMax !== null) + url_ += "completedBacktestsMax=" + encodeURIComponent("" + completedBacktestsMax) + "&"; + if (progressPercentageMin !== undefined && progressPercentageMin !== null) + url_ += "progressPercentageMin=" + encodeURIComponent("" + progressPercentageMin) + "&"; + if (progressPercentageMax !== undefined && progressPercentageMax !== null) + url_ += "progressPercentageMax=" + encodeURIComponent("" + progressPercentageMax) + "&"; + if (createdAtFrom !== undefined && createdAtFrom !== null) + url_ += "createdAtFrom=" + encodeURIComponent(createdAtFrom ? "" + createdAtFrom.toISOString() : "") + "&"; + if (createdAtTo !== undefined && createdAtTo !== null) + url_ += "createdAtTo=" + encodeURIComponent(createdAtTo ? "" + createdAtTo.toISOString() : "") + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processAdmin_GetBundleBacktestRequestsPaginated(_response); + }); + } + + protected processAdmin_GetBundleBacktestRequestsPaginated(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PaginatedBundleBacktestRequestsResponse; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + admin_GetBundleBacktestRequestsSummary(): Promise { + let url_ = this.baseUrl + "/Admin/BundleBacktestRequests/Summary"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.transformOptions(options_).then(transformedOptions_ => { + return this.http.fetch(url_, transformedOptions_); + }).then((_response: Response) => { + return this.processAdmin_GetBundleBacktestRequestsSummary(_response); + }); + } + + protected processAdmin_GetBundleBacktestRequestsSummary(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BundleBacktestRequestSummaryResponse; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } +} + export class BacktestClient extends AuthorizedApiBase { private http: { fetch(url: RequestInfo, init?: RequestInit): Promise }; private baseUrl: string; @@ -4501,6 +4621,69 @@ export interface ExchangeInitializedStatus { isInitialized?: boolean; } +export interface PaginatedBundleBacktestRequestsResponse { + bundleRequests?: BundleBacktestRequestListItemResponse[]; + totalCount?: number; + currentPage?: number; + pageSize?: number; + totalPages?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; +} + +export interface BundleBacktestRequestListItemResponse { + requestId?: string; + name?: string; + version?: number; + status?: string; + createdAt?: Date; + completedAt?: Date | null; + updatedAt?: Date; + totalBacktests?: number; + completedBacktests?: number; + failedBacktests?: number; + progressPercentage?: number; + userId?: number | null; + userName?: string | null; + errorMessage?: string | null; + currentBacktest?: string | null; + estimatedTimeRemainingSeconds?: number | null; +} + +export enum BundleBacktestRequestSortableColumn { + RequestId = "RequestId", + Name = "Name", + Status = "Status", + CreatedAt = "CreatedAt", + CompletedAt = "CompletedAt", + TotalBacktests = "TotalBacktests", + CompletedBacktests = "CompletedBacktests", + FailedBacktests = "FailedBacktests", + ProgressPercentage = "ProgressPercentage", + UserId = "UserId", + UserName = "UserName", + UpdatedAt = "UpdatedAt", +} + +export enum BundleBacktestRequestStatus { + Pending = "Pending", + Running = "Running", + Completed = "Completed", + Failed = "Failed", + Cancelled = "Cancelled", + Saved = "Saved", +} + +export interface BundleBacktestRequestSummaryResponse { + statusSummary?: BundleBacktestRequestStatusSummary[]; + totalRequests?: number; +} + +export interface BundleBacktestRequestStatusSummary { + status?: string; + count?: number; +} + export interface Backtest { id: string; finalPnl: number; @@ -4937,15 +5120,7 @@ export interface BundleBacktestRequest { progressInfo?: string | null; currentBacktest?: string | null; estimatedTimeRemainingSeconds?: number | null; -} - -export enum BundleBacktestRequestStatus { - Pending = "Pending", - Running = "Running", - Completed = "Completed", - Failed = "Failed", - Cancelled = "Cancelled", - Saved = "Saved", + updatedAt: Date; } export interface RunBundleBacktestRequest { diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index 891554fd..1426bef9 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -226,6 +226,69 @@ export interface ExchangeInitializedStatus { isInitialized?: boolean; } +export interface PaginatedBundleBacktestRequestsResponse { + bundleRequests?: BundleBacktestRequestListItemResponse[]; + totalCount?: number; + currentPage?: number; + pageSize?: number; + totalPages?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; +} + +export interface BundleBacktestRequestListItemResponse { + requestId?: string; + name?: string; + version?: number; + status?: string; + createdAt?: Date; + completedAt?: Date | null; + updatedAt?: Date; + totalBacktests?: number; + completedBacktests?: number; + failedBacktests?: number; + progressPercentage?: number; + userId?: number | null; + userName?: string | null; + errorMessage?: string | null; + currentBacktest?: string | null; + estimatedTimeRemainingSeconds?: number | null; +} + +export enum BundleBacktestRequestSortableColumn { + RequestId = "RequestId", + Name = "Name", + Status = "Status", + CreatedAt = "CreatedAt", + CompletedAt = "CompletedAt", + TotalBacktests = "TotalBacktests", + CompletedBacktests = "CompletedBacktests", + FailedBacktests = "FailedBacktests", + ProgressPercentage = "ProgressPercentage", + UserId = "UserId", + UserName = "UserName", + UpdatedAt = "UpdatedAt", +} + +export enum BundleBacktestRequestStatus { + Pending = "Pending", + Running = "Running", + Completed = "Completed", + Failed = "Failed", + Cancelled = "Cancelled", + Saved = "Saved", +} + +export interface BundleBacktestRequestSummaryResponse { + statusSummary?: BundleBacktestRequestStatusSummary[]; + totalRequests?: number; +} + +export interface BundleBacktestRequestStatusSummary { + status?: string; + count?: number; +} + export interface Backtest { id: string; finalPnl: number; @@ -662,15 +725,7 @@ export interface BundleBacktestRequest { progressInfo?: string | null; currentBacktest?: string | null; estimatedTimeRemainingSeconds?: number | null; -} - -export enum BundleBacktestRequestStatus { - Pending = "Pending", - Running = "Running", - Completed = "Completed", - Failed = "Failed", - Cancelled = "Cancelled", - Saved = "Saved", + updatedAt: Date; } export interface RunBundleBacktestRequest { diff --git a/src/Managing.WebApp/src/pages/adminPage/admin.tsx b/src/Managing.WebApp/src/pages/adminPage/admin.tsx index 077e2bf2..c0322f35 100644 --- a/src/Managing.WebApp/src/pages/adminPage/admin.tsx +++ b/src/Managing.WebApp/src/pages/adminPage/admin.tsx @@ -5,6 +5,7 @@ import {Tabs} from '../../components/mollecules' import AccountSettings from './account/accountSettings' import WhitelistSettings from './whitelist/whitelistSettings' import JobsSettings from './jobs/jobsSettings' +import BundleBacktestRequestsSettings from './bundleBacktestRequests/bundleBacktestRequestsSettings' type TabsType = { label: string @@ -29,6 +30,11 @@ const tabs: TabsType = [ index: 3, label: 'Jobs', }, + { + Component: BundleBacktestRequestsSettings, + index: 4, + label: 'Bundle', + }, ] const Admin: React.FC = () => { diff --git a/src/Managing.WebApp/src/pages/adminPage/bundleBacktestRequests/bundleBacktestRequestsSettings.tsx b/src/Managing.WebApp/src/pages/adminPage/bundleBacktestRequests/bundleBacktestRequestsSettings.tsx new file mode 100644 index 00000000..5d391d4f --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/bundleBacktestRequests/bundleBacktestRequestsSettings.tsx @@ -0,0 +1,534 @@ +import {useState} from 'react' +import {useQuery} from '@tanstack/react-query' + +import useApiUrlStore from '../../../app/store/apiStore' +import { + AdminClient, + BundleBacktestRequestSortableColumn, + BundleBacktestRequestStatus +} from '../../../generated/ManagingApi' + +import BundleBacktestRequestsTable from './bundleBacktestRequestsTable' + +const BundleBacktestRequestsSettings: React.FC = () => { + const { apiUrl } = useApiUrlStore() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(50) + const [sortBy, setSortBy] = useState(BundleBacktestRequestSortableColumn.CreatedAt) + const [sortOrder, setSortOrder] = useState('desc') + const [nameContains, setNameContains] = useState('') + const [statusFilter, setStatusFilter] = useState(BundleBacktestRequestStatus.Failed) + const [userIdFilter, setUserIdFilter] = useState('') + const [userNameContains, setUserNameContains] = useState('') + const [totalBacktestsMin, setTotalBacktestsMin] = useState('') + const [totalBacktestsMax, setTotalBacktestsMax] = useState('') + const [completedBacktestsMin, setCompletedBacktestsMin] = useState('') + const [completedBacktestsMax, setCompletedBacktestsMax] = useState('') + const [progressPercentageMin, setProgressPercentageMin] = useState('') + const [progressPercentageMax, setProgressPercentageMax] = useState('') + const [filtersOpen, setFiltersOpen] = useState(false) + const [showTable, setShowTable] = useState(true) + + const adminClient = new AdminClient({}, apiUrl) + + // Fetch bundle backtest requests summary statistics + const { + data: bundleSummary, + isLoading: isLoadingSummary + } = useQuery({ + queryKey: ['bundleBacktestRequestsSummary'], + queryFn: async () => { + return await adminClient.admin_GetBundleBacktestRequestsSummary() + }, + staleTime: 10000, // 10 seconds + gcTime: 5 * 60 * 1000, + refetchInterval: 5000, // Auto-refresh every 5 seconds + }) + + const { + data: bundleRequestsData, + isLoading, + error, + refetch + } = useQuery({ + queryKey: ['bundleBacktestRequests', page, pageSize, sortBy, sortOrder, nameContains, statusFilter, userIdFilter, userNameContains, totalBacktestsMin, totalBacktestsMax, completedBacktestsMin, completedBacktestsMax, progressPercentageMin, progressPercentageMax], + queryFn: async () => { + return await adminClient.admin_GetBundleBacktestRequestsPaginated( + page, + pageSize, + sortBy, + sortOrder, + nameContains || null, + statusFilter || null, + userIdFilter ? parseInt(userIdFilter) : null, + userNameContains || null, + totalBacktestsMin ? parseInt(totalBacktestsMin) : null, + totalBacktestsMax ? parseInt(totalBacktestsMax) : null, + completedBacktestsMin ? parseInt(completedBacktestsMin) : null, + completedBacktestsMax ? parseInt(completedBacktestsMax) : null, + progressPercentageMin ? parseFloat(progressPercentageMin) : null, + progressPercentageMax ? parseFloat(progressPercentageMax) : null, + null, // createdAtFrom + null // createdAtTo + ) + }, + enabled: showTable, + staleTime: 10000, // 10 seconds + gcTime: 5 * 60 * 1000, + refetchInterval: showTable ? 5000 : false, // Auto-refresh every 5 seconds when table is shown + }) + + const bundleRequests = bundleRequestsData?.bundleRequests || [] + const totalCount = bundleRequestsData?.totalCount || 0 + const totalPages = bundleRequestsData?.totalPages || 0 + const currentPage = bundleRequestsData?.currentPage || 1 + + const handlePageChange = (newPage: number) => { + setPage(newPage) + } + + const handleSortChange = (newSortBy: BundleBacktestRequestSortableColumn) => { + if (sortBy === newSortBy) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(newSortBy) + setSortOrder('desc') + } + } + + const handleFilterChange = () => { + setPage(1) // Reset to first page when filters change + } + + const clearFilters = () => { + setNameContains('') + setStatusFilter(null) + setUserIdFilter('') + setUserNameContains('') + setTotalBacktestsMin('') + setTotalBacktestsMax('') + setCompletedBacktestsMin('') + setCompletedBacktestsMax('') + setProgressPercentageMin('') + setProgressPercentageMax('') + setPage(1) + } + + // Helper function to get status badge color + const getStatusBadgeColor = (status: string | undefined) => { + if (!status) return 'badge-ghost' + const statusLower = status.toLowerCase() + switch (statusLower) { + case 'pending': + return 'badge-warning' + case 'running': + return 'badge-info' + case 'completed': + return 'badge-success' + case 'failed': + return 'badge-error' + case 'saved': + return 'badge-ghost' + case 'cancelled': + return 'badge-ghost' + default: + return 'badge-ghost' + } + } + + // Helper function to get status info (icon, description, color) + const getStatusInfo = (status: string) => { + const statusLower = status.toLowerCase() + let statusIcon, statusDesc, statusColor + + switch (statusLower) { + case 'pending': + statusIcon = ( + + + + ) + statusDesc = 'Waiting to be processed' + statusColor = 'text-warning' + break + case 'running': + statusIcon = ( + + + + ) + statusDesc = 'Currently processing' + statusColor = 'text-info' + break + case 'completed': + statusIcon = ( + + + + ) + statusDesc = 'Successfully finished' + statusColor = 'text-success' + break + case 'failed': + statusIcon = ( + + + + ) + statusDesc = 'Requires attention' + statusColor = 'text-error' + break + case 'saved': + statusIcon = ( + + + + ) + statusDesc = 'Saved as template' + statusColor = 'text-neutral' + break + case 'cancelled': + statusIcon = ( + + + + ) + statusDesc = 'Cancelled by user' + statusColor = 'text-neutral' + break + default: + statusIcon = ( + + + + ) + statusDesc = 'Unknown status' + statusColor = 'text-base-content' + } + + return { statusIcon, statusDesc, statusColor } + } + + // All possible statuses for skeleton display + const allStatuses = ['Pending', 'Running', 'Completed', 'Failed', 'Saved', 'Cancelled'] + + return ( + + {/* Bundle Backtest Requests Summary Statistics */} + + + {/* Status Overview Section */} + + + + + + + Status Overview + {isLoadingSummary && ( + + )} + + + {isLoadingSummary ? ( + // Show skeleton with all statuses set to 0 + <> + {allStatuses.map((status) => { + const { statusIcon, statusDesc, statusColor } = getStatusInfo(status) + return ( + + + + + {statusIcon} + + {status} + 0 + {statusDesc} + + + + ) + })} + {/* Total card in skeleton */} + + + + + + + + + Total + 0 + Across all statuses + + + + > + ) : bundleSummary?.statusSummary && bundleSummary.statusSummary.length > 0 ? ( + // Show actual data + <> + {bundleSummary.statusSummary.map((statusItem) => { + const { statusIcon, statusDesc, statusColor } = getStatusInfo(statusItem.status || '') + return ( + + + + + {statusIcon} + + {statusItem.status || 'Unknown'} + {statusItem.count || 0} + {statusDesc} + + + + ) + })} + {/* Total card with actual data */} + + + + + + + + + Total + {bundleSummary?.totalRequests || 0} + Across all statuses + + + + > + ) : null} + + + + + + + {/* Filters Section */} + + + + Filters + + setFiltersOpen(!filtersOpen)} + > + {filtersOpen ? 'Hide' : 'Show'} Filters + + + Clear Filters + + + + + {filtersOpen && ( + + + + Name Contains + + { + setNameContains(e.target.value) + handleFilterChange() + }} + /> + + + + + Status + + { + setStatusFilter(e.target.value ? (e.target.value as BundleBacktestRequestStatus) : null) + handleFilterChange() + }} + > + All + Pending + Running + Completed + Failed + Saved + Cancelled + + + + + + User ID + + { + setUserIdFilter(e.target.value) + handleFilterChange() + }} + /> + + + + + User Name Contains + + { + setUserNameContains(e.target.value) + handleFilterChange() + }} + /> + + + + + Total Backtests Min + + { + setTotalBacktestsMin(e.target.value) + handleFilterChange() + }} + /> + + + + + Total Backtests Max + + { + setTotalBacktestsMax(e.target.value) + handleFilterChange() + }} + /> + + + + + Completed Backtests Min + + { + setCompletedBacktestsMin(e.target.value) + handleFilterChange() + }} + /> + + + + + Completed Backtests Max + + { + setCompletedBacktestsMax(e.target.value) + handleFilterChange() + }} + /> + + + + + Progress % Min (0-100) + + { + setProgressPercentageMin(e.target.value) + handleFilterChange() + }} + /> + + + + + Progress % Max (0-100) + + { + setProgressPercentageMax(e.target.value) + handleFilterChange() + }} + /> + + + )} + + + + + + {error && ( + + Failed to load bundle backtest requests. Please try again. + + )} + + ) +} + +export default BundleBacktestRequestsSettings + diff --git a/src/Managing.WebApp/src/pages/adminPage/bundleBacktestRequests/bundleBacktestRequestsTable.tsx b/src/Managing.WebApp/src/pages/adminPage/bundleBacktestRequests/bundleBacktestRequestsTable.tsx new file mode 100644 index 00000000..df087ec9 --- /dev/null +++ b/src/Managing.WebApp/src/pages/adminPage/bundleBacktestRequests/bundleBacktestRequestsTable.tsx @@ -0,0 +1,292 @@ +import React, {useMemo} from 'react' +import { + type BundleBacktestRequestListItemResponse, + BundleBacktestRequestSortableColumn +} from '../../../generated/ManagingApi' +import {Table} from '../../../components/mollecules' + +interface IBundleBacktestRequestsTable { + bundleRequests: BundleBacktestRequestListItemResponse[] + isLoading: boolean + totalCount: number + currentPage: number + totalPages: number + pageSize: number + sortBy: BundleBacktestRequestSortableColumn + sortOrder: string + onPageChange: (page: number) => void + onSortChange: (sortBy: BundleBacktestRequestSortableColumn) => void +} + +const BundleBacktestRequestsTable: React.FC = ({ + bundleRequests, + isLoading, + totalCount, + currentPage, + totalPages, + pageSize, + sortBy, + sortOrder, + onPageChange, + onSortChange +}) => { + const getStatusBadge = (status: string | null | undefined) => { + if (!status) return - + + const statusLower = status.toLowerCase() + switch (statusLower) { + case 'pending': + return Pending + case 'running': + return Running + case 'completed': + return Completed + case 'failed': + return Failed + case 'saved': + return Saved + case 'cancelled': + return Cancelled + default: + return {status} + } + } + + const formatDate = (date: Date | string | null | undefined) => { + if (!date) return '-' + try { + return new Date(date).toLocaleString() + } catch { + return '-' + } + } + + const formatProgress = (progress: number | undefined) => { + if (progress === undefined || progress === null) return '-' + return `${progress.toFixed(1)}%` + } + + const SortableHeader = ({ column, label }: { column: BundleBacktestRequestSortableColumn; label: string }) => { + const isActive = sortBy === column + return ( + onSortChange(column)} + > + {label} + {isActive && ( + + {sortOrder === 'asc' ? '↑' : '↓'} + + )} + + ) + } + + const columns = useMemo(() => [ + { + id: 'name', + Header: () => , + accessor: (row: BundleBacktestRequestListItemResponse) => ( + + {row.name || '-'} + {row.version && v{row.version}} + + ) + }, + { + id: 'status', + Header: () => , + accessor: (row: BundleBacktestRequestListItemResponse) => getStatusBadge(row.status) + }, + { + id: 'user', + Header: () => , + accessor: (row: BundleBacktestRequestListItemResponse) => ( + + {row.userName ? ( + <> + {row.userName} + {row.userId && ID: {row.userId}} + > + ) : ( + - + )} + + ) + }, + { + id: 'progress', + Header: () => , + accessor: (row: BundleBacktestRequestListItemResponse) => ( + + {formatProgress(row.progressPercentage)} + + {row.completedBacktests || 0} / {row.totalBacktests || 0} + + {(row.totalBacktests || 0) > 0 && ( + + )} + + ) + }, + { + id: 'backtests', + Header: () => , + accessor: (row: BundleBacktestRequestListItemResponse) => ( + + Total: {row.totalBacktests || 0} + + Completed: {row.completedBacktests || 0} | Failed: {row.failedBacktests || 0} + + + ) + }, + { + id: 'dates', + Header: () => , + accessor: (row: BundleBacktestRequestListItemResponse) => ( + + Created: {formatDate(row.createdAt)} + {row.completedAt && ( + Completed: {formatDate(row.completedAt)} + )} + {row.updatedAt && ( + Updated: {formatDate(row.updatedAt)} + )} + + ) + }, + { + id: 'error', + Header: 'Error', + accessor: (row: BundleBacktestRequestListItemResponse) => ( + row.errorMessage ? ( + + Error + + ) : ( + - + ) + ) + }, + { + id: 'requestId', + Header: () => , + accessor: (row: BundleBacktestRequestListItemResponse) => ( + {row.requestId?.substring(0, 8)}... + ) + } + ], [sortBy, sortOrder, onSortChange]) + + const tableData = useMemo(() => { + return bundleRequests.map((request) => ({ + ...request, + key: request.requestId + })) + }, [bundleRequests]) + + return ( + + {isLoading && ( + + + + )} + + {!isLoading && bundleRequests.length === 0 && ( + + No bundle backtest requests found. + + )} + + {!isLoading && bundleRequests.length > 0 && ( + <> + + + + + {/* Pagination Info and Controls */} + + + Total requests: {totalCount} | Page {currentPage} of {totalPages} + + + {/* Manual Pagination */} + {totalPages > 1 && ( + + onPageChange(1)} + disabled={currentPage === 1} + > + {'<<'} + + onPageChange(currentPage - 1)} + disabled={currentPage === 1} + > + {'<'} + + + {/* Page numbers */} + + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum + if (totalPages <= 5) { + pageNum = i + 1 + } else if (currentPage <= 3) { + pageNum = i + 1 + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i + } else { + pageNum = currentPage - 2 + i + } + + return ( + onPageChange(pageNum)} + > + {pageNum} + + ) + })} + + + onPageChange(currentPage + 1)} + disabled={currentPage >= totalPages} + > + {'>'} + + onPageChange(totalPages)} + disabled={currentPage >= totalPages} + > + {'>>'} + + + )} + + > + )} + + ) +} + +export default BundleBacktestRequestsTable +
+ Total requests: {totalCount} | Page {currentPage} of {totalPages} +