From fb002e54b702c5b5d76346f2a22d1870d2c8884a Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 24 Aug 2025 14:08:26 +0200 Subject: [PATCH] Add top users by credentials to admin all time stats (#1136) --- .../Main/Models/ServerStatistics.cs | 5 + .../Main/Models/TopUserByCredentials.cs | 29 ++++ .../Main/Pages/Dashboard/AllTimeStats.razor | 122 ++++++++++++++- .../Services/StatisticsService.cs | 139 ++++++------------ .../AliasVault.Admin/wwwroot/css/tailwind.css | 12 +- 5 files changed, 201 insertions(+), 106 deletions(-) create mode 100644 apps/server/AliasVault.Admin/Main/Models/TopUserByCredentials.cs diff --git a/apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs b/apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs index 0402edc48..edc05e391 100644 --- a/apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs +++ b/apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs @@ -47,6 +47,11 @@ public class ServerStatistics /// public List TopUsersByEmails { get; set; } = new(); + /// + /// Gets or sets the list of top users by number of credentials. + /// + public List TopUsersByCredentials { get; set; } = new(); + /// /// Gets or sets the list of top IP addresses by user activity. /// diff --git a/apps/server/AliasVault.Admin/Main/Models/TopUserByCredentials.cs b/apps/server/AliasVault.Admin/Main/Models/TopUserByCredentials.cs new file mode 100644 index 000000000..7a194b6da --- /dev/null +++ b/apps/server/AliasVault.Admin/Main/Models/TopUserByCredentials.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Admin.Main.Models; + +/// +/// Model representing a user with high number of credentials. +/// +public class TopUserByCredentials +{ + /// + /// Gets or sets the user ID. + /// + public string UserId { get; set; } = string.Empty; + + /// + /// Gets or sets the username. + /// + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the number of credentials. + /// + public int CredentialCount { get; set; } +} diff --git a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/AllTimeStats.razor b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/AllTimeStats.razor index 9a1affdbb..6d5f6179e 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Dashboard/AllTimeStats.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Dashboard/AllTimeStats.razor @@ -87,7 +87,7 @@ -
+
@@ -100,16 +100,22 @@ {
+ @{ + var storageStartIndex = (_storageCurrentPage - 1) * _pageSize + 1; + var storageIndex = storageStartIndex; + } @foreach (var user in _topUsersByStorage) { + @storageIndex @user.Username - @user.StorageDisplaySize + @user.StorageDisplaySize + storageIndex++; }
@@ -123,6 +129,48 @@ }
+ + +
+
+
+

Top Users by Credentials

+

Users with the most credentials stored in their vault

+
+
+ @if (_topUsersByCredentials != null) + { +
+ + @{ + var credentialStartIndex = (_credentialCurrentPage - 1) * _pageSize + 1; + var credentialIndex = credentialStartIndex; + } + @foreach (var user in _topUsersByCredentials) + { + + @credentialIndex + + + @user.Username + + + @user.CredentialCount.ToString("N0") + + credentialIndex++; + } + +
+ + } + else + { +
+ +
+ } +
+
@@ -135,16 +183,22 @@ {
+ @{ + var aliasStartIndex = (_aliasCurrentPage - 1) * _pageSize + 1; + var aliasIndex = aliasStartIndex; + } @foreach (var user in _topUsersByAliases) { + @aliasIndex @user.Username - @user.AliasCount.ToString("N0") + @user.AliasCount.ToString("N0") + aliasIndex++; }
@@ -163,23 +217,29 @@

Top Users by Emails

-

Users with the most emails stored in their aliases

+

Users with the most emails stored

@if (_topUsersByEmails != null) {
+ @{ + var emailStartIndex = (_emailCurrentPage - 1) * _pageSize + 1; + var emailIndex = emailStartIndex; + } @foreach (var user in _topUsersByEmails) { + @emailIndex @user.Username - @user.EmailCount.ToString("N0") + @user.EmailCount.ToString("N0") + emailIndex++; }
@@ -195,6 +255,7 @@
+
@if (_topIpAddresses != null && _topIpAddresses.Any()) {
@@ -248,6 +309,7 @@
} +
@if (_loadingError) { @@ -278,6 +340,7 @@ private List? _topUsersByStorage; private List? _topUsersByAliases; private List? _topUsersByEmails; + private List? _topUsersByCredentials; private List? _topIpAddresses; private bool _ipAddressesLoading = true; private bool _loadingError = false; @@ -287,28 +350,40 @@ private int _storageCurrentPage = 1; private int _aliasCurrentPage = 1; private int _emailCurrentPage = 1; + private int _credentialCurrentPage = 1; private int _storageTotalRecords = 0; private int _aliasTotalRecords = 0; private int _emailTotalRecords = 0; + private int _credentialTotalRecords = 0; private readonly List _storageTableColumns = new() { + new() { Title = "#", PropertyName = "Index", Sortable = false }, new() { Title = "User", PropertyName = "Username", Sortable = false }, new() { Title = "Storage", PropertyName = "StorageDisplaySize", Sortable = false } }; private readonly List _aliasTableColumns = new() { + new() { Title = "#", PropertyName = "Index", Sortable = false }, new() { Title = "User", PropertyName = "Username", Sortable = false }, new() { Title = "Aliases", PropertyName = "AliasCount", Sortable = false } }; private readonly List _emailTableColumns = new() { + new() { Title = "#", PropertyName = "Index", Sortable = false }, new() { Title = "User", PropertyName = "Username", Sortable = false }, new() { Title = "Emails", PropertyName = "EmailCount", Sortable = false } }; + private readonly List _credentialTableColumns = new() + { + new() { Title = "#", PropertyName = "Index", Sortable = false }, + new() { Title = "User", PropertyName = "Username", Sortable = false }, + new() { Title = "Credentials", PropertyName = "CredentialCount", Sortable = false } + }; + private readonly List _ipTableColumns = new() { new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false }, @@ -337,6 +412,7 @@ _topUsersByStorage = null; _topUsersByAliases = null; _topUsersByEmails = null; + _topUsersByCredentials = null; _topIpAddresses = null; _ipAddressesLoading = true; _loadingError = false; @@ -345,9 +421,11 @@ _storageCurrentPage = 1; _aliasCurrentPage = 1; _emailCurrentPage = 1; + _credentialCurrentPage = 1; _storageTotalRecords = 0; _aliasTotalRecords = 0; _emailTotalRecords = 0; + _credentialTotalRecords = 0; StateHasChanged(); @@ -381,17 +459,19 @@ { try { - // Load paginated data for all three tables + // Load paginated data for all four tables var storageTask = StatisticsService.GetTopUsersByStoragePaginatedAsync(_storageCurrentPage, _pageSize); var aliasTask = StatisticsService.GetTopUsersByAliasesPaginatedAsync(_aliasCurrentPage, _pageSize); var emailTask = StatisticsService.GetTopUsersByEmailsPaginatedAsync(_emailCurrentPage, _pageSize); + var credentialTask = StatisticsService.GetTopUsersByCredentialsPaginatedAsync(_credentialCurrentPage, _pageSize); var ipTask = StatisticsService.GetServerStatisticsAsync(); - await Task.WhenAll(storageTask, aliasTask, emailTask, ipTask); + await Task.WhenAll(storageTask, aliasTask, emailTask, credentialTask, ipTask); var (storageUsers, storageTotalCount) = await storageTask; var (aliasUsers, aliasTotalCount) = await aliasTask; var (emailUsers, emailTotalCount) = await emailTask; + var (credentialUsers, credentialTotalCount) = await credentialTask; var stats = await ipTask; _topUsersByStorage = storageUsers; @@ -403,6 +483,9 @@ _topUsersByEmails = emailUsers; _emailTotalRecords = emailTotalCount; + _topUsersByCredentials = credentialUsers; + _credentialTotalRecords = credentialTotalCount; + _topIpAddresses = stats.TopIpAddresses; _ipAddressesLoading = false; @@ -435,6 +518,12 @@ await LoadEmailPageData(); } + private async Task HandleCredentialPageChanged(int newPage) + { + _credentialCurrentPage = newPage; + await LoadCredentialPageData(); + } + private async Task LoadStoragePageData() { try @@ -491,4 +580,23 @@ GlobalNotificationService.AddErrorMessage($"Error loading email statistics: {ex.Message}"); } } + + private async Task LoadCredentialPageData() + { + try + { + _topUsersByCredentials = null; + StateHasChanged(); + + var (users, totalCount) = await StatisticsService.GetTopUsersByCredentialsPaginatedAsync(_credentialCurrentPage, _pageSize); + _topUsersByCredentials = users; + _credentialTotalRecords = totalCount; + + StateHasChanged(); + } + catch (Exception ex) + { + GlobalNotificationService.AddErrorMessage($"Error loading credential statistics: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/apps/server/AliasVault.Admin/Services/StatisticsService.cs b/apps/server/AliasVault.Admin/Services/StatisticsService.cs index 3516df5b1..18a1f9201 100644 --- a/apps/server/AliasVault.Admin/Services/StatisticsService.cs +++ b/apps/server/AliasVault.Admin/Services/StatisticsService.cs @@ -87,9 +87,15 @@ public class StatisticsService stats.TotalEmailAttachments = results[3]; // Get top users data - stats.TopUsersByStorage = await GetTopUsersByStorageAsync(10); - stats.TopUsersByAliases = await GetTopUsersByAliasesAsync(10); - stats.TopUsersByEmails = await GetTopUsersByEmailsAsync(10); + var (storageUsers, _) = await GetTopUsersByStoragePaginatedAsync(1, 10); + var (aliasUsers, _) = await GetTopUsersByAliasesPaginatedAsync(1, 10); + var (emailUsers, _) = await GetTopUsersByEmailsPaginatedAsync(1, 10); + var (credentialUsers, _) = await GetTopUsersByCredentialsPaginatedAsync(1, 10); + + stats.TopUsersByStorage = storageUsers; + stats.TopUsersByAliases = aliasUsers; + stats.TopUsersByEmails = emailUsers; + stats.TopUsersByCredentials = credentialUsers; stats.TopIpAddresses = await GetTopIpAddressesAsync(); return stats; @@ -236,6 +242,45 @@ public class StatisticsService return (users, totalCount); } + /// + /// Gets paginated top users by number of credentials. + /// + /// Page number (1-based). + /// Number of items per page. + /// Paginated list of top users by credentials with total count. + public async Task<(List Users, int TotalCount)> GetTopUsersByCredentialsPaginatedAsync(int page, int pageSize) + { + await using var context = await _contextFactory.CreateDbContextAsync(); + + // Get total count - using latest vault for each user + var totalCount = await context.Vaults + .GroupBy(v => v.UserId) + .CountAsync(); + + // Get paginated data - using latest vault version for each user + var topUsers = await context.Vaults + .GroupBy(v => v.UserId) + .Select(g => new + { + UserId = g.Key, + Username = g.First().User.UserName, + CredentialCount = g.OrderByDescending(v => v.Version).First().CredentialsCount, + }) + .OrderByDescending(u => u.CredentialCount) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + var users = topUsers.Select(u => new TopUserByCredentials + { + UserId = u.UserId, + Username = u.Username ?? UnknownUsername, + CredentialCount = u.CredentialCount, + }).ToList(); + + return (users, totalCount); + } + /// /// Gets user-specific usage statistics for both all-time and recent periods. /// @@ -387,94 +432,6 @@ public class StatisticsService return await context.EmailAttachments.CountAsync(); } - /// - /// Gets the top users by vault storage size. - /// - /// Number of top users to retrieve. - /// List of top users by storage. - private async Task> GetTopUsersByStorageAsync(int limit = 10) - { - await using var context = await _contextFactory.CreateDbContextAsync(); - - // Get the latest vault for each user with their total storage - var topUsers = await context.Vaults - .GroupBy(v => v.UserId) - .Select(g => new - { - UserId = g.Key, - Username = g.First().User.UserName, - TotalStorageBytes = g.OrderByDescending(v => v.Version).First().FileSize, - }) - .OrderByDescending(u => u.TotalStorageBytes) - .Take(limit) - .ToListAsync(); - - return topUsers.Select(u => new TopUserByStorage - { - UserId = u.UserId, - Username = u.Username ?? UnknownUsername, - StorageBytes = u.TotalStorageBytes, - StorageDisplaySize = FormatKilobytes(u.TotalStorageBytes), - }).ToList(); - } - - /// - /// Gets the top users by number of email aliases. - /// - /// Number of top users to retrieve. - /// List of top users by aliases. - private async Task> GetTopUsersByAliasesAsync(int limit = 10) - { - await using var context = await _contextFactory.CreateDbContextAsync(); - var topUsers = await context.UserEmailClaims - .Where(uec => uec.UserId != null) - .GroupBy(uec => uec.UserId) - .Select(g => new - { - UserId = g.Key, - Username = g.First().User!.UserName, - AliasCount = g.Count(), - }) - .OrderByDescending(u => u.AliasCount) - .Take(limit) - .ToListAsync(); - - return topUsers.Select(u => new TopUserByAliases - { - UserId = u.UserId!, - Username = u.Username ?? UnknownUsername, - AliasCount = u.AliasCount, - }).ToList(); - } - - /// - /// Gets the top users by number of emails stored. - /// - /// Number of top users to retrieve. - /// List of top users by emails. - private async Task> GetTopUsersByEmailsAsync(int limit = 10) - { - await using var context = await _contextFactory.CreateDbContextAsync(); - var topUsers = await context.Emails - .GroupBy(e => e.EncryptionKey.UserId) - .Select(g => new - { - UserId = g.Key, - Username = g.First().EncryptionKey.User!.UserName, - EmailCount = g.Count(), - }) - .OrderByDescending(u => u.EmailCount) - .Take(limit) - .ToListAsync(); - - return topUsers.Select(u => new TopUserByEmails - { - UserId = u.UserId!, - Username = u.Username ?? UnknownUsername, - EmailCount = u.EmailCount, - }).ToList(); - } - /// /// Gets the top 10 IP address ranges by number of associated user accounts. /// Only includes non-anonymized IPs (not "xxx.xxx.xxx.xxx"). diff --git a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css index d74eb48de..0a9f92d58 100644 --- a/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Admin/wwwroot/css/tailwind.css @@ -884,10 +884,6 @@ video { width: 50%; } -.w-1\/3 { - width: 33.333333%; -} - .w-1\/4 { width: 25%; } @@ -904,10 +900,6 @@ video { width: 4rem; } -.w-2\/3 { - width: 66.666667%; -} - .w-20 { width: 5rem; } @@ -2690,6 +2682,10 @@ video { display: block; } + .xl\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }