diff --git a/apps/server/AliasVault.Admin/Main/Pages/Emails.razor b/apps/server/AliasVault.Admin/Main/Pages/Emails.razor index 0c2a10602..b0f592b42 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Emails.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Emails.razor @@ -15,7 +15,7 @@ Email Storage Stats - + @@ -88,6 +88,7 @@ else private int PageSize { get; set; } = 50; private int TotalRecords { get; set; } private string _searchTerm = string.Empty; + private CancellationTokenSource? _searchCancellationTokenSource; /// /// The last search term. @@ -102,7 +103,9 @@ else if (_searchTerm != value) { _searchTerm = value; - _ = RefreshData(); + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); } } } @@ -113,7 +116,7 @@ else { SortColumn = sort.column; SortDirection = sort.direction; - await RefreshData(); + await RefreshData(CancellationToken.None); } /// @@ -134,53 +137,65 @@ else _searchTerm = SearchTermFromQuery; } - await RefreshData(); + await RefreshData(CancellationToken.None); } } private void HandlePageChanged(int newPage) { CurrentPage = newPage; - _ = RefreshData(); + _ = RefreshData(CancellationToken.None); } - private async Task RefreshData() + private async Task RefreshData(CancellationToken cancellationToken = default) { - IsLoading = true; - StateHasChanged(); - - await using var dbContext = await DbContextFactory.CreateDbContextAsync(); - - IQueryable query = dbContext.Emails; - - query = ApplySearchFilter(query); - query = ApplySort(query); - - TotalRecords = await query.CountAsync(); - var emailList = await query - .Skip((CurrentPage - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); - - // Get all usernames for the emails in the current list - var encryptionKeyIds = emailList.Select(x => x.UserEncryptionKeyId).Distinct().ToList(); - var encryptionKeyUsernames = await dbContext.UserEncryptionKeys - .Where(x => encryptionKeyIds.Contains(x.Id)) - .Join(dbContext.AliasVaultUsers, x => x.UserId, y => y.Id, (x, y) => new { EncryptionKeyId = x.Id, UserId = y.Id, y.UserName }) - .ToListAsync(); - - // Create new list of viewmodels - EmailViewModelList = new List(); - - foreach (var email in emailList) + try { - var encryptionKey = encryptionKeyUsernames.FirstOrDefault(x => x.EncryptionKeyId == email.UserEncryptionKeyId); - EmailViewModelList.Add(new EmailViewModel { Email = email, UserId = encryptionKey?.UserId ?? string.Empty, UserName = encryptionKey?.UserName ?? string.Empty }); - } + IsLoading = true; + StateHasChanged(); - IsLoading = false; - IsInitialized = true; - StateHasChanged(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); + + IQueryable query = dbContext.Emails; + + query = ApplySearchFilter(query); + query = ApplySort(query); + + TotalRecords = await query.CountAsync(cancellationToken); + var emailList = await query + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // Get all usernames for the emails in the current list + var encryptionKeyIds = emailList.Select(x => x.UserEncryptionKeyId).Distinct().ToList(); + var encryptionKeyUsernames = await dbContext.UserEncryptionKeys + .Where(x => encryptionKeyIds.Contains(x.Id)) + .Join(dbContext.AliasVaultUsers, x => x.UserId, y => y.Id, (x, y) => new { EncryptionKeyId = x.Id, UserId = y.Id, y.UserName }) + .ToListAsync(cancellationToken); + + // Create new list of viewmodels + EmailViewModelList = new List(); + + foreach (var email in emailList) + { + var encryptionKey = encryptionKeyUsernames.FirstOrDefault(x => x.EncryptionKeyId == email.UserEncryptionKeyId); + EmailViewModelList.Add(new EmailViewModel { Email = email, UserId = encryptionKey?.UserId ?? string.Empty, UserName = encryptionKey?.UserName ?? string.Empty }); + } + + IsLoading = false; + IsInitialized = true; + StateHasChanged(); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested, do nothing + } } /// @@ -254,4 +269,14 @@ else public string UserId { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty; } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource?.Dispose(); + } + base.Dispose(disposing); + } } diff --git a/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor b/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor index eec295568..c61193e04 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Logging/Auth.razor @@ -14,7 +14,7 @@ Description="This page shows an overview of recent auth attempts."> - + @@ -107,6 +107,8 @@ else private string _searchTerm = string.Empty; private string _lastSearchTerm = string.Empty; + private CancellationTokenSource? _searchCancellationTokenSource; + private string SearchTerm { get => _searchTerm; @@ -115,7 +117,9 @@ else if (_searchTerm != value) { _searchTerm = value; - _ = RefreshData(); + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); } } } @@ -129,7 +133,9 @@ else if (_selectedEventType != value) { _selectedEventType = value; - _ = RefreshData(); + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); } } } @@ -139,7 +145,7 @@ else private void ToggleUniqueUsernames() { ShowUniqueUsernames = !ShowUniqueUsernames; - _ = RefreshData(); + _ = RefreshData(CancellationToken.None); } private string SortColumn { get; set; } = "Id"; @@ -149,7 +155,7 @@ else { SortColumn = sort.column; SortDirection = sort.direction; - await RefreshData(); + await RefreshData(CancellationToken.None); } /// @@ -161,7 +167,7 @@ else Navigation.LocationChanged += OnLocationChanged; ParseQueryAndRefresh(); - await RefreshData(); + await RefreshData(CancellationToken.None); } private void OnLocationChanged(object? sender, LocationChangedEventArgs e) @@ -186,7 +192,7 @@ else if (_searchTerm != _lastSearchTerm) { _lastSearchTerm = _searchTerm; - _ = RefreshData(); // Fire and forget + _ = RefreshData(CancellationToken.None); // Fire and forget } } @@ -194,96 +200,115 @@ else public void Dispose() { Navigation.LocationChanged -= OnLocationChanged; + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource?.Dispose(); } private void HandlePageChanged(int newPage) { CurrentPage = newPage; - _ = RefreshData(); + _ = RefreshData(CancellationToken.None); } - private async Task RefreshData() + private async Task RefreshData(CancellationToken cancellationToken = default) { - IsLoading = true; - StateHasChanged(); - - await using var dbContext = await DbContextFactory.CreateDbContextAsync(); - var query = dbContext.AuthLogs.AsQueryable(); - - if (!string.IsNullOrEmpty(SearchTerm)) + try { - // Reset page number back to 1 if the search term has changed. - if (SearchTerm != _lastSearchTerm) - { - CurrentPage = 1; - } - _lastSearchTerm = SearchTerm; + IsLoading = true; + StateHasChanged(); - // If the search term starts with "client:", we search for the client header. - if (SearchTerm.StartsWith("client:", StringComparison.OrdinalIgnoreCase)) + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); + var query = dbContext.AuthLogs.AsQueryable(); + + if (!string.IsNullOrEmpty(SearchTerm)) { - var clientSearchTerm = SearchTerm.Substring(7).ToLower(); - query = query.Where(x => EF.Functions.Like((x.Client ?? string.Empty).ToLower(), "%" + clientSearchTerm + "%")); + // Reset page number back to 1 if the search term has changed. + if (SearchTerm != _lastSearchTerm) + { + CurrentPage = 1; + } + _lastSearchTerm = SearchTerm; + + // If the search term starts with "client:", we search for the client header. + if (SearchTerm.StartsWith("client:", StringComparison.OrdinalIgnoreCase)) + { + var clientSearchTerm = SearchTerm.Substring(7).ToLower(); + query = query.Where(x => EF.Functions.Like((x.Client ?? string.Empty).ToLower(), "%" + clientSearchTerm + "%")); + } + else + { + var searchTerm = SearchTerm.Trim().ToLower(); + query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + searchTerm + "%") || + EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + searchTerm + "%")); + } + } + + if (!string.IsNullOrEmpty(SelectedEventType)) + { + var success = Enum.TryParse(SelectedEventType, out var eventType); + if (success) + { + query = query.Where(x => x.EventType == eventType); + } + } + + query = ApplySort(query); + + // Handle unique usernames filtering after getting all results (since GroupBy with OrderBy in Select is complex for EF) + if (ShowUniqueUsernames) + { + // Get all matching records first + var allLogs = await query.ToListAsync(cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // Group by username and take the latest entry for each username + var uniqueLogs = allLogs + .GroupBy(x => x.Username) + .Select(g => g.OrderByDescending(x => x.Timestamp).First()) + .ToList(); + + // Apply pagination to the unique results + TotalRecords = uniqueLogs.Count; + LogList = uniqueLogs + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToList(); } else { - var searchTerm = SearchTerm.Trim().ToLower(); - query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + searchTerm + "%") || - EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + searchTerm + "%")); + TotalRecords = await query.CountAsync(cancellationToken); + LogList = await query + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + return; + } } - } - if (!string.IsNullOrEmpty(SelectedEventType)) + // Create user lookup dictionary for the current page + var usernames = LogList.Select(x => x.Username).Distinct().Where(x => !string.IsNullOrEmpty(x)).ToList(); + var users = await dbContext.AliasVaultUsers + .Where(u => u.UserName != null && usernames.Contains(u.UserName)) + .Select(u => new { u.UserName, u.Id }) + .ToListAsync(cancellationToken); + + UserLookup = users.Where(u => u.UserName != null).ToDictionary(u => u.UserName!, u => u.Id); + + IsLoading = false; + IsInitialized = true; + StateHasChanged(); + } + catch (OperationCanceledException) { - var success = Enum.TryParse(SelectedEventType, out var eventType); - if (success) - { - query = query.Where(x => x.EventType == eventType); - } + // Expected when cancellation is requested, do nothing } - - query = ApplySort(query); - - // Handle unique usernames filtering after getting all results (since GroupBy with OrderBy in Select is complex for EF) - if (ShowUniqueUsernames) - { - // Get all matching records first - var allLogs = await query.ToListAsync(); - - // Group by username and take the latest entry for each username - var uniqueLogs = allLogs - .GroupBy(x => x.Username) - .Select(g => g.OrderByDescending(x => x.Timestamp).First()) - .ToList(); - - // Apply pagination to the unique results - TotalRecords = uniqueLogs.Count; - LogList = uniqueLogs - .Skip((CurrentPage - 1) * PageSize) - .Take(PageSize) - .ToList(); - } - else - { - TotalRecords = await query.CountAsync(); - LogList = await query - .Skip((CurrentPage - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); - } - - // Create user lookup dictionary for the current page - var usernames = LogList.Select(x => x.Username).Distinct().Where(x => !string.IsNullOrEmpty(x)).ToList(); - var users = await dbContext.AliasVaultUsers - .Where(u => u.UserName != null && usernames.Contains(u.UserName)) - .Select(u => new { u.UserName, u.Id }) - .ToListAsync(); - - UserLookup = users.Where(u => u.UserName != null).ToDictionary(u => u.UserName!, u => u.Id); - - IsLoading = false; - IsInitialized = true; - StateHasChanged(); } /// @@ -356,7 +381,7 @@ else await using var dbContext = await DbContextFactory.CreateDbContextAsync(); dbContext.AuthLogs.RemoveRange(dbContext.AuthLogs); await dbContext.SaveChangesAsync(); - await RefreshData(); + await RefreshData(CancellationToken.None); IsLoading = false; StateHasChanged(); diff --git a/apps/server/AliasVault.Admin/Main/Pages/Logging/General.razor b/apps/server/AliasVault.Admin/Main/Pages/Logging/General.razor index 3d1114525..669754095 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Logging/General.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Logging/General.razor @@ -10,7 +10,7 @@ Description="This page shows an overview of recent system logs."> - + @@ -102,6 +102,8 @@ else private string _searchTerm = string.Empty; private string _lastSearchTerm = string.Empty; + private CancellationTokenSource? _searchCancellationTokenSource; + private string SearchTerm { get => _searchTerm; @@ -110,7 +112,9 @@ else if (_searchTerm != value) { _searchTerm = value; - _ = RefreshData(); + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); } } } @@ -124,7 +128,9 @@ else if (_selectedServiceName != value) { _selectedServiceName = value; - _ = RefreshData(); + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); } } } @@ -138,7 +144,7 @@ else { SortColumn = sort.column; SortDirection = sort.direction; - await RefreshData(); + await RefreshData(CancellationToken.None); } /// @@ -155,66 +161,78 @@ else { await using var dbContext = await DbContextFactory.CreateDbContextAsync(); ServiceNames = await dbContext.Logs.Select(l => l.Application).Distinct().ToListAsync(); - await RefreshData(); + await RefreshData(CancellationToken.None); } } private void HandlePageChanged(int newPage) { CurrentPage = newPage; - _ = RefreshData(); + _ = RefreshData(CancellationToken.None); } - private async Task RefreshData() + private async Task RefreshData(CancellationToken cancellationToken = default) { - IsLoading = true; - StateHasChanged(); - - await using var dbContext = await DbContextFactory.CreateDbContextAsync(); - var query = dbContext.Logs.AsQueryable(); - - query = ApplySearchTermFilter(query); - query = ApplyServiceNameFilter(query); - - // Apply sort. - switch (SortColumn) + try { - case "Application": - query = SortDirection == SortDirection.Ascending - ? query.OrderBy(x => x.Application) - : query.OrderByDescending(x => x.Application); - break; - case "Message": - query = SortDirection == SortDirection.Ascending - ? query.OrderBy(x => x.Message) - : query.OrderByDescending(x => x.Message); - break; - case "Level": - query = SortDirection == SortDirection.Ascending - ? query.OrderBy(x => x.Level) - : query.OrderByDescending(x => x.Level); - break; - case "Timestamp": - query = SortDirection == SortDirection.Ascending - ? query.OrderBy(x => x.TimeStamp) - : query.OrderByDescending(x => x.TimeStamp); - break; - default: - query = SortDirection == SortDirection.Ascending - ? query.OrderBy(x => x.Id) - : query.OrderByDescending(x => x.Id); - break; + IsLoading = true; + StateHasChanged(); + + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); + var query = dbContext.Logs.AsQueryable(); + + query = ApplySearchTermFilter(query); + query = ApplyServiceNameFilter(query); + + // Apply sort. + switch (SortColumn) + { + case "Application": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Application) + : query.OrderByDescending(x => x.Application); + break; + case "Message": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Message) + : query.OrderByDescending(x => x.Message); + break; + case "Level": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Level) + : query.OrderByDescending(x => x.Level); + break; + case "Timestamp": + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.TimeStamp) + : query.OrderByDescending(x => x.TimeStamp); + break; + default: + query = SortDirection == SortDirection.Ascending + ? query.OrderBy(x => x.Id) + : query.OrderByDescending(x => x.Id); + break; + } + + TotalRecords = await query.CountAsync(cancellationToken); + LogList = await query + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .ToListAsync(cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + IsLoading = false; + IsInitialized = true; + StateHasChanged(); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested, do nothing } - - TotalRecords = await query.CountAsync(); - LogList = await query - .Skip((CurrentPage - 1) * PageSize) - .Take(PageSize) - .ToListAsync(); - - IsLoading = false; - IsInitialized = true; - StateHasChanged(); } /// @@ -272,9 +290,19 @@ else await using var dbContext = await DbContextFactory.CreateDbContextAsync(); dbContext.Logs.RemoveRange(dbContext.Logs); await dbContext.SaveChangesAsync(); - await RefreshData(); + await RefreshData(CancellationToken.None); IsLoading = false; StateHasChanged(); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource?.Dispose(); + } + base.Dispose(disposing); + } } diff --git a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor index e25ac817d..69f817f80 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Users/Users.razor @@ -9,7 +9,7 @@ Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")" Description="This page shows an overview of all registered users and the associated vaults."> - + @@ -86,6 +86,7 @@ else private int TotalRecords { get; set; } private string _searchTerm = string.Empty; + private CancellationTokenSource? _searchCancellationTokenSource; /// /// The last search term. @@ -100,7 +101,9 @@ else if (_searchTerm != value) { _searchTerm = value; - _ = RefreshData(); + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + _ = RefreshData(_searchCancellationTokenSource.Token); } } } @@ -112,7 +115,7 @@ else { SortColumn = sort.column; SortDirection = sort.direction; - await RefreshData(); + await RefreshData(CancellationToken.None); } /// @@ -127,72 +130,84 @@ else { if (firstRender) { - await RefreshData(); + await RefreshData(CancellationToken.None); } } private void HandlePageChanged(int newPage) { CurrentPage = newPage; - _ = RefreshData(); + _ = RefreshData(CancellationToken.None); } - private async Task RefreshData() + private async Task RefreshData(CancellationToken cancellationToken = default) { - IsLoading = true; - StateHasChanged(); - - await using var dbContext = await DbContextFactory.CreateDbContextAsync(); - IQueryable query = dbContext.AliasVaultUsers; - - query = ApplySearchFilter(query); - query = ApplySort(query, dbContext); - - TotalRecords = await query.CountAsync(); - var users = await query - .Skip((CurrentPage - 1) * PageSize) - .Take(PageSize) - .Select(u => new - { - u.Id, - u.UserName, - u.CreatedAt, - u.TwoFactorEnabled, - u.Blocked, - Vaults = u.Vaults.Select(v => new - { - v.FileSize, - v.CreatedAt, - v.RevisionNumber, - CredentialCount = v.CredentialsCount, - }), - EmailClaims = u.EmailClaims.Select(ec => new - { - ec.CreatedAt, - ec.Address - }), - ReceivedEmails = u.EmailClaims.SelectMany(ec => dbContext.Emails.Where(e => e.To == ec.Address)).Count(), - }) - .ToListAsync(); - - UserList = users.Select(user => new UserViewModel + try { - Id = user.Id, - UserName = user.UserName?.ToLower() ?? "N/A", - TwoFactorEnabled = user.TwoFactorEnabled, - Blocked = user.Blocked, - CreatedAt = user.CreatedAt, - VaultCount = user.Vaults.Count(), - CredentialCount = user.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialCount, - EmailClaimCount = user.EmailClaims.Count(), - ReceivedEmailCount = user.ReceivedEmails, - VaultStorageInKb = user.Vaults.Sum(x => x.FileSize), - LastVaultUpdate = user.Vaults.Any() ? user.Vaults.Max(x => x.CreatedAt) : user.CreatedAt, - }).ToList(); + IsLoading = true; + StateHasChanged(); - IsLoading = false; - IsInitialized = true; - StateHasChanged(); + await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken); + IQueryable query = dbContext.AliasVaultUsers; + + query = ApplySearchFilter(query); + query = ApplySort(query, dbContext); + + TotalRecords = await query.CountAsync(cancellationToken); + var users = await query + .Skip((CurrentPage - 1) * PageSize) + .Take(PageSize) + .Select(u => new + { + u.Id, + u.UserName, + u.CreatedAt, + u.TwoFactorEnabled, + u.Blocked, + Vaults = u.Vaults.Select(v => new + { + v.FileSize, + v.CreatedAt, + v.RevisionNumber, + CredentialCount = v.CredentialsCount, + }), + EmailClaims = u.EmailClaims.Select(ec => new + { + ec.CreatedAt, + ec.Address + }), + ReceivedEmails = u.EmailClaims.SelectMany(ec => dbContext.Emails.Where(e => e.To == ec.Address)).Count(), + }) + .ToListAsync(cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + UserList = users.Select(user => new UserViewModel + { + Id = user.Id, + UserName = user.UserName?.ToLower() ?? "N/A", + TwoFactorEnabled = user.TwoFactorEnabled, + Blocked = user.Blocked, + CreatedAt = user.CreatedAt, + VaultCount = user.Vaults.Count(), + CredentialCount = user.Vaults.OrderByDescending(x => x.RevisionNumber).First().CredentialCount, + EmailClaimCount = user.EmailClaims.Count(), + ReceivedEmailCount = user.ReceivedEmails, + VaultStorageInKb = user.Vaults.Sum(x => x.FileSize), + LastVaultUpdate = user.Vaults.Any() ? user.Vaults.Max(x => x.CreatedAt) : user.CreatedAt, + }).ToList(); + + IsLoading = false; + IsInitialized = true; + StateHasChanged(); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested, do nothing + } } /// @@ -279,4 +294,14 @@ else return query; } + protected override void Dispose(bool disposing) + { + if (disposing) + { + _searchCancellationTokenSource?.Cancel(); + _searchCancellationTokenSource?.Dispose(); + } + base.Dispose(disposing); + } + }