Add all-time stats page to admin (#967)

This commit is contained in:
Leendert de Borst
2025-06-27 13:05:58 +02:00
committed by Leendert de Borst
parent e31f3df45b
commit 11d8c941d2
10 changed files with 1060 additions and 13 deletions

View File

@@ -0,0 +1,49 @@
//-----------------------------------------------------------------------
// <copyright file="ServerStatistics.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 comprehensive server statistics and metrics.
/// </summary>
public class ServerStatistics
{
/// <summary>
/// Gets or sets the total number of users registered on the server.
/// </summary>
public int TotalUsers { get; set; }
/// <summary>
/// Gets or sets the total number of email aliases created.
/// </summary>
public int TotalAliases { get; set; }
/// <summary>
/// Gets or sets the total number of emails stored.
/// </summary>
public int TotalEmails { get; set; }
/// <summary>
/// Gets or sets the total number of email attachments.
/// </summary>
public int TotalEmailAttachments { get; set; }
/// <summary>
/// Gets or sets the list of top users by storage size.
/// </summary>
public List<TopUserByStorage> TopUsersByStorage { get; set; } = new();
/// <summary>
/// Gets or sets the list of top users by number of aliases.
/// </summary>
public List<TopUserByAliases> TopUsersByAliases { get; set; } = new();
/// <summary>
/// Gets or sets the list of top IP addresses by user activity.
/// </summary>
public List<TopIpAddress> TopIpAddresses { get; set; } = new();
}

View File

@@ -0,0 +1,34 @@
//-----------------------------------------------------------------------
// <copyright file="TopIpAddress.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 an IP address with high user activity.
/// </summary>
public class TopIpAddress
{
/// <summary>
/// Gets or sets the original (non-anonymized) IP address for filtering.
/// </summary>
public string OriginalIpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the anonymized IP address for display.
/// </summary>
public string IpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of unique users from this IP.
/// </summary>
public int UniqueUserCount { get; set; }
/// <summary>
/// Gets or sets the last activity timestamp.
/// </summary>
public DateTime LastActivity { get; set; }
}

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="TopUserByAliases.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 many email aliases.
/// </summary>
public class TopUserByAliases
{
/// <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 aliases.
/// </summary>
public int AliasCount { get; set; }
}

View File

@@ -0,0 +1,34 @@
//-----------------------------------------------------------------------
// <copyright file="TopUserByStorage.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 storage usage.
/// </summary>
public class TopUserByStorage
{
/// <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 storage size in bytes.
/// </summary>
public long StorageBytes { get; set; }
/// <summary>
/// Gets or sets the human-readable storage size.
/// </summary>
public string StorageDisplaySize { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,336 @@
@page "/all-time-stats"
@using AliasVault.Admin.Main.Models
@using AliasVault.Admin.Services
@using AliasVault.Admin.Main.Pages.Dashboard.Components
@using AliasVault.RazorComponents.Tables
@inherits MainBase
@inject StatisticsService StatisticsService
<LayoutPageTitle>All-Time Statistics</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="All-Time Statistics"
Description="View metrics and insights into the usage totals of this AliasVault server.">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
<div class="px-4 space-y-6">
<!-- Overview Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<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">
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Users</h3>
@if (_totalUsers.HasValue)
{
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalUsers.Value.ToString("N0")</p>
}
else
{
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
}
</div>
</div>
</div>
<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">
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Email Aliases</h3>
@if (_totalAliases.HasValue)
{
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalAliases.Value.ToString("N0")</p>
}
else
{
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
}
</div>
</div>
</div>
<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">
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Emails</h3>
@if (_totalEmails.HasValue)
{
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalEmails.Value.ToString("N0")</p>
}
else
{
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
}
</div>
</div>
</div>
<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">
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Email attachments</h3>
@if (_totalEmailAttachments.HasValue)
{
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalEmailAttachments.Value.ToString("N0")</p>
}
else
{
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
}
</div>
</div>
</div>
</div>
<!-- Top Users Analysis -->
<div class="grid grid-cols-1 lg: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">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top Users by Storage</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the largest vault storage requirements</p>
</div>
</div>
@if (_topUsersByStorage != null)
{
<div class="overflow-x-auto">
<SortableTable Columns="@_storageTableColumns">
@foreach (var user in _topUsersByStorage)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<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>
</tr>
}
</SortableTable>
</div>
}
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">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top Users by Aliases</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most email aliases created</p>
</div>
</div>
@if (_topUsersByAliases != null)
{
<div class="overflow-x-auto">
<SortableTable Columns="@_aliasTableColumns">
@foreach (var user in _topUsersByAliases)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<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>
</tr>
}
</SortableTable>
</div>
}
else
{
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
}
</div>
</div>
<!-- Top IP Addresses -->
@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">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top IP Address Ranges</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">IP ranges with the most associated user accounts (last octet anonymized, successful logins only)</p>
</div>
</div>
<div class="overflow-x-auto">
<SortableTable Columns="@_ipTableColumns">
@foreach (var ip in _topIpAddresses)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100 font-mono">
<a href="/logging/auth?search=@Uri.EscapeDataString(ip.OriginalIpAddress)" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@ip.IpAddress
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@ip.UniqueUserCount.ToString("N0")</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@ip.LastActivity.ToString("MMM dd, yyyy HH:mm")</td>
</tr>
}
</SortableTable>
</div>
</div>
}
else 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">
<div class="text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No IP Address Data</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">IP addresses are fully anonymized or no successful logins recorded.</p>
</div>
</div>
}
else if (_ipAddressesLoading)
{
<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 IP Address Ranges</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">IP ranges with the most associated user accounts (last octet anonymized, successful logins only)</p>
</div>
</div>
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
</div>
}
@if (_loadingError)
{
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Statistics</h3>
<p class="text-sm text-red-700 dark:text-red-300 mt-1">Unable to retrieve some server statistics. Please try refreshing the page.</p>
</div>
</div>
</div>
}
</div>
@code {
// Basic statistics (loaded first for immediate feedback)
private int? _totalUsers;
private int? _totalAliases;
private int? _totalEmails;
private int? _totalEmailAttachments;
// Detailed statistics (loaded separately)
private List<TopUserByStorage>? _topUsersByStorage;
private List<TopUserByAliases>? _topUsersByAliases;
private List<TopIpAddress>? _topIpAddresses;
private bool _ipAddressesLoading = true;
private bool _loadingError = false;
private readonly List<TableColumn> _storageTableColumns = new()
{
new() { Title = "User", PropertyName = "Username", Sortable = false },
new() { Title = "Storage", PropertyName = "StorageDisplaySize", Sortable = false }
};
private readonly List<TableColumn> _aliasTableColumns = new()
{
new() { Title = "User", PropertyName = "Username", Sortable = false },
new() { Title = "Aliases", PropertyName = "AliasCount", Sortable = false }
};
private readonly List<TableColumn> _ipTableColumns = new()
{
new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false },
new() { Title = "Users", PropertyName = "UniqueUserCount", Sortable = false },
new() { Title = "Last Activity", PropertyName = "LastActivity", Sortable = false }
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Load statistics asynchronously for better perceived performance
_ = LoadBasicStatisticsAsync();
_ = LoadDetailedStatisticsAsync();
}
private void RefreshData()
{
// Reset all data
_totalUsers = null;
_totalAliases = null;
_totalEmails = null;
_totalEmailAttachments = null;
_topUsersByStorage = null;
_topUsersByAliases = null;
_topIpAddresses = null;
_ipAddressesLoading = true;
_loadingError = false;
StateHasChanged();
// Reload statistics
_ = LoadBasicStatisticsAsync();
_ = LoadDetailedStatisticsAsync();
}
private async Task LoadBasicStatisticsAsync()
{
try
{
var stats = await StatisticsService.GetServerStatisticsAsync();
_totalUsers = stats.TotalUsers;
_totalAliases = stats.TotalAliases;
_totalEmails = stats.TotalEmails;
_totalEmailAttachments = stats.TotalEmailAttachments;
StateHasChanged();
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Error loading basic statistics: {ex.Message}");
_loadingError = true;
StateHasChanged();
}
}
private async Task LoadDetailedStatisticsAsync()
{
try
{
var stats = await StatisticsService.GetServerStatisticsAsync();
_topUsersByStorage = stats.TopUsersByStorage;
_topUsersByAliases = stats.TopUsersByAliases;
_topIpAddresses = stats.TopIpAddresses;
_ipAddressesLoading = false;
StateHasChanged();
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Error loading detailed statistics: {ex.Message}");
_loadingError = true;
_ipAddressesLoading = false;
StateHasChanged();
}
}
}

View File

@@ -9,6 +9,12 @@
Title="AliasVault Admin"
Description="Welcome to the AliasVault admin dashboard. Below you can find statistics about recent activity on this server.">
<CustomActions>
<a href="/all-time-stats" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-200 mr-3">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
</svg>
All-Time Stats
</a>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>

View File

@@ -10,7 +10,7 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="@(TotalRecords > 0 ? $"Auth logs ({TotalRecords:N0})" : "Auth logs")"
Title="@GetTitle()"
Description="This page shows an overview of recent auth attempts.">
<CustomActions>
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
@@ -25,13 +25,13 @@
<div class="mb-3 flex space-x-4">
<div class="flex w-full">
<div class="w-2/3 pr-2">
<div class="w-1/2 pr-2">
<div class="relative">
<SearchIcon />
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
</div>
</div>
<div class="w-1/3 pl-2">
<div class="w-1/4 px-1">
<select @bind="SelectedEventType" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
<option value="">All event types</option>
@foreach (var eventType in Enum.GetValues<AuthEventType>())
@@ -40,6 +40,12 @@
}
</select>
</div>
<div class="w-1/4 pl-2">
<button type="button" @onclick="ToggleUniqueUsernames"
class="w-full px-4 py-2 text-sm font-medium rounded border transition-colors duration-200 @(ShowUniqueUsernames ? "bg-orange-400 text-white border-orange-500 hover:bg-orange-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600")">
@(ShowUniqueUsernames ? "✓ Unique users" : "Unique users")
</button>
</div>
</div>
</div>
</div>
@@ -128,6 +134,14 @@ else
}
}
private bool ShowUniqueUsernames { get; set; } = false;
private void ToggleUniqueUsernames()
{
ShowUniqueUsernames = !ShowUniqueUsernames;
_ = RefreshData();
}
private string SortColumn { get; set; } = "Id";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
@@ -228,11 +242,33 @@ else
query = ApplySort(query);
TotalRecords = await query.CountAsync();
LogList = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
// 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();
@@ -296,6 +332,9 @@ else
return query;
}
/// <summary>
/// Show confirmation modal to delete all logs.
/// </summary>
private async Task DeleteLogsWithConfirmation()
{
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))
@@ -304,6 +343,9 @@ else
}
}
/// <summary>
/// Delete all logs.
/// </summary>
private async Task DeleteLogs()
{
IsLoading = true;
@@ -317,4 +359,24 @@ else
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// Get the title of the page.
/// </summary>
/// <returns>The title of the page.</returns>
private string GetTitle()
{
if (TotalRecords == 0)
{
return "Auth logs";
}
var title = $"Auth logs ({TotalRecords:N0})";
if (ShowUniqueUsernames)
{
title += " - Unique usernames";
}
return title;
}
}

View File

@@ -61,6 +61,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthLoggingService>();
builder.Services.AddScoped<ConfirmModalService>();
builder.Services.AddScoped<ServerSettingsService>();
builder.Services.AddTransient<StatisticsService>();
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
builder.Services.AddApexCharts();

View File

@@ -0,0 +1,238 @@
//-----------------------------------------------------------------------
// <copyright file="StatisticsService.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.Services;
using AliasServerDb;
using AliasVault.Admin.Main.Models;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Service for gathering comprehensive server statistics and metrics.
/// </summary>
public class StatisticsService
{
private readonly IAliasServerDbContextFactory _contextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="StatisticsService"/> class.
/// </summary>
/// <param name="contextFactory">Database context factory.</param>
public StatisticsService(IAliasServerDbContextFactory contextFactory)
{
_contextFactory = contextFactory;
}
/// <summary>
/// Gets comprehensive server statistics including counts, storage metrics, and top users.
/// </summary>
/// <returns>Server statistics object.</returns>
public async Task<ServerStatistics> GetServerStatisticsAsync()
{
var stats = new ServerStatistics();
// Get basic counts in parallel
var tasks = new[]
{
GetTotalUsersAsync(),
GetTotalAliasesAsync(),
GetTotalEmailsAsync(),
GetTotalEmailAttachmentsAsync(),
};
var results = await Task.WhenAll(tasks);
stats.TotalUsers = results[0];
stats.TotalAliases = results[1];
stats.TotalEmails = results[2];
stats.TotalEmailAttachments = results[3];
// Get top users data
stats.TopUsersByStorage = await GetTopUsersByStorageAsync();
stats.TopUsersByAliases = await GetTopUsersByAliasesAsync();
stats.TopIpAddresses = await GetTopIpAddressesAsync();
return stats;
}
/// <summary>
/// Formats kilobytes into human-readable format.
/// </summary>
/// <param name="kilobytes">Number of kilobytes.</param>
/// <returns>Formatted string (e.g., "1.5 MB").</returns>
private static string FormatKilobytes(long kilobytes)
{
string[] suffixes = { "KB", "MB", "GB", "TB" };
int counter = 0;
decimal number = kilobytes;
while (Math.Round(number / 1024) >= 1)
{
number /= 1024;
counter++;
}
return $"{number:n1} {suffixes[counter]}";
}
/// <summary>
/// Anonymizes the last octet of an IP address for privacy.
/// </summary>
/// <param name="ipAddress">The IP address to anonymize.</param>
/// <returns>Anonymized IP address.</returns>
private static string AnonymizeIpAddress(string ipAddress)
{
if (ipAddress == "x.x.x.x")
{
return ipAddress;
}
var parts = ipAddress.Split('.');
if (parts.Length == 4)
{
return $"{parts[0]}.{parts[1]}.{parts[2]}.xxx";
}
// Handle IPv6 or other formats by masking the last segment
var lastColonIndex = ipAddress.LastIndexOf(':');
if (lastColonIndex > 0)
{
return ipAddress[..lastColonIndex] + ":xxx";
}
return "xxx.xxx.xxx.xxx";
}
/// <summary>
/// Gets the total number of users.
/// </summary>
/// <returns>Total user count.</returns>
private async Task<int> GetTotalUsersAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
return await context.AliasVaultUsers.CountAsync();
}
/// <summary>
/// Gets the total number of email aliases created.
/// </summary>
/// <returns>Total alias count.</returns>
private async Task<int> GetTotalAliasesAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
return await context.UserEmailClaims.CountAsync();
}
/// <summary>
/// Gets the total number of emails stored.
/// </summary>
/// <returns>Total email count.</returns>
private async Task<int> GetTotalEmailsAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
return await context.Emails.CountAsync();
}
/// <summary>
/// Gets the total number of email attachments.
/// </summary>
/// <returns>Total email attachment count.</returns>
private async Task<int> GetTotalEmailAttachmentsAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
return await context.EmailAttachments.CountAsync();
}
/// <summary>
/// Gets the top 10 users by vault storage size.
/// </summary>
/// <returns>List of top users by storage.</returns>
private async Task<List<TopUserByStorage>> GetTopUsersByStorageAsync()
{
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(10)
.ToListAsync();
return topUsers.Select(u => new TopUserByStorage
{
UserId = u.UserId,
Username = u.Username ?? "Unknown",
StorageBytes = u.TotalStorageBytes,
StorageDisplaySize = FormatKilobytes(u.TotalStorageBytes),
}).ToList();
}
/// <summary>
/// Gets the top 10 users by number of email aliases.
/// </summary>
/// <returns>List of top users by aliases.</returns>
private async Task<List<TopUserByAliases>> GetTopUsersByAliasesAsync()
{
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(10)
.ToListAsync();
return topUsers.Select(u => new TopUserByAliases
{
UserId = u.UserId!,
Username = u.Username ?? "Unknown",
AliasCount = u.AliasCount,
}).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").
/// </summary>
/// <returns>List of top IP addresses.</returns>
private async Task<List<TopIpAddress>> GetTopIpAddressesAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
// Get distinct IP addresses from successful auth logs only, excluding fully anonymized ones
var ipStats = await context.AuthLogs
.Where(al => al.IpAddress != null && al.IpAddress != "xxx.xxx.xxx.xxx" && al.IsSuccess)
.GroupBy(al => al.IpAddress)
.Select(g => new
{
IpAddress = g.Key,
UniqueUsernames = g.Where(al => al.IsSuccess).Select(al => al.Username).Distinct().Count(),
LastActivity = g.Max(al => al.Timestamp),
})
.OrderByDescending(ip => ip.UniqueUsernames)
.Take(10)
.ToListAsync();
return ipStats.Select(ip => new TopIpAddress
{
OriginalIpAddress = ip.IpAddress!,
IpAddress = AnonymizeIpAddress(ip.IpAddress!),
UniqueUserCount = ip.UniqueUsernames,
LastActivity = ip.LastActivity,
}).ToList();
}
}

View File

@@ -691,6 +691,10 @@ video {
margin-left: 0.75rem;
}
.ml-4 {
margin-left: 1rem;
}
.ml-auto {
margin-left: auto;
}
@@ -794,6 +798,10 @@ video {
height: 2.5rem;
}
.h-12 {
height: 3rem;
}
.h-20 {
height: 5rem;
}
@@ -814,6 +822,10 @@ video {
height: 1.5rem;
}
.h-64 {
height: 16rem;
}
.h-7 {
height: 1.75rem;
}
@@ -846,6 +858,10 @@ video {
width: 2.5rem;
}
.w-12 {
width: 3rem;
}
.w-2\/3 {
width: 66.666667%;
}
@@ -894,6 +910,18 @@ video {
width: 100%;
}
.w-16 {
width: 4rem;
}
.w-1\/4 {
width: 25%;
}
.min-w-full {
min-width: 100%;
}
.max-w-2xl {
max-width: 42rem;
}
@@ -928,6 +956,16 @@ video {
animation: spin 1s linear infinite;
}
@keyframes pulse {
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.cursor-not-allowed {
cursor: not-allowed;
}
@@ -1004,6 +1042,10 @@ video {
gap: 1rem;
}
.gap-6 {
gap: 1.5rem;
}
.gap-8 {
gap: 2rem;
}
@@ -1145,10 +1187,19 @@ video {
border-bottom-width: 1px;
}
.border-b-2 {
border-bottom-width: 2px;
}
.border-l-4 {
border-left-width: 4px;
}
.border-blue-500 {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
@@ -1169,6 +1220,11 @@ video {
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.border-red-200 {
--tw-border-opacity: 1;
border-color: rgb(254 202 202 / var(--tw-border-opacity));
}
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity));
@@ -1179,6 +1235,26 @@ video {
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
.border-blue-600 {
--tw-border-opacity: 1;
border-color: rgb(37 99 235 / var(--tw-border-opacity));
}
.border-orange-600 {
--tw-border-opacity: 1;
border-color: rgb(234 88 12 / var(--tw-border-opacity));
}
.border-orange-400 {
--tw-border-opacity: 1;
border-color: rgb(251 146 60 / var(--tw-border-opacity));
}
.border-orange-500 {
--tw-border-opacity: 1;
border-color: rgb(249 115 22 / var(--tw-border-opacity));
}
.bg-blue-100 {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
@@ -1274,6 +1350,11 @@ video {
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
.bg-orange-500 {
--tw-bg-opacity: 1;
background-color: rgb(249 115 22 / var(--tw-bg-opacity));
}
.bg-primary-100 {
--tw-bg-opacity: 1;
background-color: rgb(253 222 133 / var(--tw-bg-opacity));
@@ -1304,6 +1385,11 @@ video {
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
}
.bg-purple-500 {
--tw-bg-opacity: 1;
background-color: rgb(168 85 247 / var(--tw-bg-opacity));
}
.bg-red-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity));
@@ -1349,6 +1435,26 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-orange-600 {
--tw-bg-opacity: 1;
background-color: rgb(234 88 12 / var(--tw-bg-opacity));
}
.bg-orange-400 {
--tw-bg-opacity: 1;
background-color: rgb(251 146 60 / var(--tw-bg-opacity));
}
.bg-orange-300 {
--tw-bg-opacity: 1;
background-color: rgb(253 186 116 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1452,6 +1558,11 @@ video {
padding-bottom: 0.75rem;
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.py-6 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
@@ -1462,6 +1573,11 @@ video {
padding-bottom: 2rem;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}
@@ -1506,11 +1622,24 @@ video {
text-align: center;
}
.text-right {
text-align: right;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.text-5xl {
font-size: 3rem;
line-height: 1;
@@ -1541,11 +1670,6 @@ video {
line-height: 1rem;
}
.text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.font-bold {
font-weight: 700;
}
@@ -1566,6 +1690,10 @@ video {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
@@ -1578,6 +1706,10 @@ video {
line-height: 2.25rem;
}
.tracking-wider {
letter-spacing: 0.05em;
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
@@ -1623,11 +1755,26 @@ video {
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.text-green-600 {
--tw-text-opacity: 1;
color: rgb(22 163 74 / var(--tw-text-opacity));
}
.text-green-800 {
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity));
}
.text-indigo-600 {
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
}
.text-orange-600 {
--tw-text-opacity: 1;
color: rgb(234 88 12 / var(--tw-text-opacity));
}
.text-primary-600 {
--tw-text-opacity: 1;
color: rgb(214 131 56 / var(--tw-text-opacity));
@@ -1638,6 +1785,16 @@ video {
color: rgb(184 112 47 / var(--tw-text-opacity));
}
.text-purple-600 {
--tw-text-opacity: 1;
color: rgb(147 51 234 / var(--tw-text-opacity));
}
.text-red-400 {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
@@ -1668,6 +1825,11 @@ video {
color: rgb(133 77 14 / var(--tw-text-opacity));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}
@@ -1738,6 +1900,10 @@ video {
transition-duration: 300ms;
}
.duration-200 {
transition-duration: 200ms;
}
.hover\:bg-gray-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -1783,6 +1949,26 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-gray-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-orange-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(194 65 12 / var(--tw-bg-opacity));
}
.hover\:bg-orange-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(234 88 12 / var(--tw-bg-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
@@ -1808,6 +1994,16 @@ video {
color: rgb(154 93 38 / var(--tw-text-opacity));
}
.hover\:text-blue-800:hover {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.hover\:text-blue-600:hover {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -1879,6 +2075,15 @@ video {
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
}
.focus\:ring-gray-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
@@ -1928,6 +2133,11 @@ video {
border-color: rgb(234 179 8 / var(--tw-border-opacity));
}
.dark\:border-red-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(153 27 27 / var(--tw-border-opacity));
}
.dark\:bg-blue-800:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
@@ -2103,6 +2313,31 @@ video {
color: rgb(254 240 138 / var(--tw-text-opacity));
}
.dark\:text-green-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(74 222 128 / var(--tw-text-opacity));
}
.dark\:text-indigo-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(129 140 248 / var(--tw-text-opacity));
}
.dark\:text-orange-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(251 146 60 / var(--tw-text-opacity));
}
.dark\:text-purple-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(192 132 252 / var(--tw-text-opacity));
}
.dark\:text-red-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(252 165 165 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
@@ -2157,6 +2392,21 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:text-gray-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
}
.dark\:hover\:text-blue-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(147 197 253 / var(--tw-text-opacity));
}
.dark\:hover\:text-blue-400:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
@@ -2385,6 +2635,14 @@ video {
width: auto;
}
.lg\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.lg\:flex-row {
flex-direction: row;
}