Add top users by credentials to admin all time stats (#1136)

This commit is contained in:
Leendert de Borst
2025-08-24 14:08:26 +02:00
committed by Leendert de Borst
parent 58ae63c74b
commit fb002e54b7
5 changed files with 201 additions and 106 deletions

View File

@@ -47,6 +47,11 @@ public class ServerStatistics
/// </summary>
public List<TopUserByEmails> TopUsersByEmails { get; set; } = new();
/// <summary>
/// Gets or sets the list of top users by number of credentials.
/// </summary>
public List<TopUserByCredentials> TopUsersByCredentials { get; set; } = new();
/// <summary>
/// Gets or sets the list of top IP addresses by user activity.
/// </summary>

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="TopUserByCredentials.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Models;
/// <summary>
/// Model representing a user with high number of credentials.
/// </summary>
public class TopUserByCredentials
{
/// <summary>
/// Gets or sets the user ID.
/// </summary>
public string UserId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of credentials.
/// </summary>
public int CredentialCount { get; set; }
}

View File

@@ -87,7 +87,7 @@
</div>
<!-- Top Users Analysis -->
<div class="space-y-6">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- Top Users by Storage -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
@@ -100,16 +100,22 @@
{
<div class="overflow-x-auto">
<SortableTable Columns="@_storageTableColumns">
@{
var storageStartIndex = (_storageCurrentPage - 1) * _pageSize + 1;
var storageIndex = storageStartIndex;
}
@foreach (var user in _topUsersByStorage)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@storageIndex</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="users/@user.UserId" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@user.Username
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@user.StorageDisplaySize</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right w-32">@user.StorageDisplaySize</td>
</tr>
storageIndex++;
}
</SortableTable>
</div>
@@ -123,6 +129,48 @@
}
</div>
<!-- Top Users by Credentials -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top Users by Credentials</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most credentials stored in their vault</p>
</div>
</div>
@if (_topUsersByCredentials != null)
{
<div class="overflow-x-auto">
<SortableTable Columns="@_credentialTableColumns">
@{
var credentialStartIndex = (_credentialCurrentPage - 1) * _pageSize + 1;
var credentialIndex = credentialStartIndex;
}
@foreach (var user in _topUsersByCredentials)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@credentialIndex</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="users/@user.UserId" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@user.Username
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right w-24">@user.CredentialCount.ToString("N0")</td>
</tr>
credentialIndex++;
}
</SortableTable>
</div>
<Paginator CurrentPage="_credentialCurrentPage" PageSize="_pageSize" TotalRecords="_credentialTotalRecords" OnPageChanged="HandleCredentialPageChanged" />
}
else
{
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
}
</div>
<!-- Top Users by Aliases -->
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
@@ -135,16 +183,22 @@
{
<div class="overflow-x-auto">
<SortableTable Columns="@_aliasTableColumns">
@{
var aliasStartIndex = (_aliasCurrentPage - 1) * _pageSize + 1;
var aliasIndex = aliasStartIndex;
}
@foreach (var user in _topUsersByAliases)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@aliasIndex</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="users/@user.UserId" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@user.Username
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@user.AliasCount.ToString("N0")</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right w-24">@user.AliasCount.ToString("N0")</td>
</tr>
aliasIndex++;
}
</SortableTable>
</div>
@@ -163,23 +217,29 @@
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top Users by Emails</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most emails stored in their aliases</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most emails stored</p>
</div>
</div>
@if (_topUsersByEmails != null)
{
<div class="overflow-x-auto">
<SortableTable Columns="@_emailTableColumns">
@{
var emailStartIndex = (_emailCurrentPage - 1) * _pageSize + 1;
var emailIndex = emailStartIndex;
}
@foreach (var user in _topUsersByEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@emailIndex</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="users/@user.UserId" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@user.Username
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@user.EmailCount.ToString("N0")</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right w-24">@user.EmailCount.ToString("N0")</td>
</tr>
emailIndex++;
}
</SortableTable>
</div>
@@ -195,6 +255,7 @@
</div>
<!-- Top IP Addresses -->
<div class="mt-6">
@if (_topIpAddresses != null && _topIpAddresses.Any())
{
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
@@ -248,6 +309,7 @@
</div>
</div>
}
</div>
@if (_loadingError)
{
@@ -278,6 +340,7 @@
private List<TopUserByStorage>? _topUsersByStorage;
private List<TopUserByAliases>? _topUsersByAliases;
private List<TopUserByEmails>? _topUsersByEmails;
private List<TopUserByCredentials>? _topUsersByCredentials;
private List<TopIpAddress>? _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<TableColumn> _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<TableColumn> _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<TableColumn> _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<TableColumn> _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<TableColumn> _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}");
}
}
}

View File

@@ -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);
}
/// <summary>
/// Gets paginated top users by number of credentials.
/// </summary>
/// <param name="page">Page number (1-based).</param>
/// <param name="pageSize">Number of items per page.</param>
/// <returns>Paginated list of top users by credentials with total count.</returns>
public async Task<(List<TopUserByCredentials> 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);
}
/// <summary>
/// Gets user-specific usage statistics for both all-time and recent periods.
/// </summary>
@@ -387,94 +432,6 @@ public class StatisticsService
return await context.EmailAttachments.CountAsync();
}
/// <summary>
/// Gets the top users by vault storage size.
/// </summary>
/// <param name="limit">Number of top users to retrieve.</param>
/// <returns>List of top users by storage.</returns>
private async Task<List<TopUserByStorage>> 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();
}
/// <summary>
/// Gets the top users by number of email aliases.
/// </summary>
/// <param name="limit">Number of top users to retrieve.</param>
/// <returns>List of top users by aliases.</returns>
private async Task<List<TopUserByAliases>> 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();
}
/// <summary>
/// Gets the top users by number of emails stored.
/// </summary>
/// <param name="limit">Number of top users to retrieve.</param>
/// <returns>List of top users by emails.</returns>
private async Task<List<TopUserByEmails>> 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();
}
/// <summary>
/// Gets the top 10 IP address ranges by number of associated user accounts.
/// Only includes non-anonymized IPs (not "xxx.xxx.xxx.xxx").

View File

@@ -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));
}