Compare commits

...

3 Commits

Author SHA1 Message Date
Leendert de Borst
c0cbc0be7b Only include successful attempts in statistics (#1420) 2025-12-01 23:08:38 +01:00
Leendert de Borst
40686f97e0 Show last 30 days instead of 72h for deletion requests (#1420) 2025-12-01 20:17:31 +01:00
Leendert de Borst
f10fb989ce Update recent usage page to include more info (#1420) 2025-12-01 16:25:59 +00:00
11 changed files with 316 additions and 12 deletions

View File

@@ -0,0 +1,34 @@
//-----------------------------------------------------------------------
// <copyright file="RecentUsageAccountDeletions.cs" company="aliasvault">
// Copyright (c) aliasvault. 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 usernames with most account deletions in the last 30 days.
/// </summary>
public class RecentUsageAccountDeletions
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of account deletions for this username in the last 30 days.
/// </summary>
public int DeletionCount30d { get; set; }
/// <summary>
/// Gets or sets the date when the most recent account with this username was registered.
/// </summary>
public DateTime? LastRegistrationDate { get; set; }
/// <summary>
/// Gets or sets the date when the most recent account with this username was deleted.
/// </summary>
public DateTime? LastDeletionDate { get; set; }
}

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="RecentUsageDeletionsByIp.cs" company="aliasvault">
// Copyright (c) aliasvault. 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 IP addresses with most account deletions in the last 30 days.
/// </summary>
public class RecentUsageDeletionsByIp
{
/// <summary>
/// Gets or sets the original IP address (for linking purposes).
/// </summary>
public string OriginalIpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the anonymized IP address.
/// </summary>
public string IpAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of account deletions from this IP in the last 30 days.
/// </summary>
public int DeletionCount30d { get; set; }
}

View File

@@ -31,4 +31,14 @@ public class RecentUsageStatistics
/// Gets or sets the list of IP addresses with most mobile login requests in the last 72 hours.
/// </summary>
public List<RecentUsageMobileLogins> TopIpsByMobileLogins72h { get; set; } = new();
/// <summary>
/// Gets or sets the list of IP addresses with most account deletions in the last 30 days.
/// </summary>
public List<RecentUsageDeletionsByIp> TopIpsByDeletions30d { get; set; } = new();
/// <summary>
/// Gets or sets the list of usernames with most account deletions in the last 30 days.
/// </summary>
public List<RecentUsageAccountDeletions> TopUsernamesByDeletions30d { get; set; } = new();
}

View File

@@ -0,0 +1,83 @@
@using AliasVault.Admin.Main.Models
@using AliasVault.RazorComponents.Tables
<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 Usernames by Account Deletions (Last 30d)</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Usernames with the most account deletion events in the last 30 days</p>
</div>
</div>
@if (Data != null && Data.Any())
{
<div class="mb-3">
<Paginator CurrentPage="@CurrentPage" PageSize="@PageSize" TotalRecords="@Data.Count" OnPageChanged="@HandlePageChanged" />
</div>
<div class="overflow-x-auto">
<SortableTable Columns="@_tableColumns">
@foreach (var deletion in PagedData)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">
<a href="logging/auth?search=@Uri.EscapeDataString(deletion.Username)" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@deletion.Username
</a>
</SortableTableColumn>
<SortableTableColumn>@deletion.DeletionCount30d.ToString("N0")</SortableTableColumn>
<SortableTableColumn>
@if (deletion.LastDeletionDate.HasValue)
{
<span>@deletion.LastDeletionDate.Value.ToString("yyyy-MM-dd HH:mm:ss") UTC</span>
}
else
{
<span class="text-gray-400 dark:text-gray-500">-</span>
}
</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
}
else if (Data != null)
{
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No Recent Account Deletions</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No account deletions occurred in the last 30 days.</p>
</div>
}
else
{
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
}
</div>
@code {
[Parameter]
public List<RecentUsageAccountDeletions>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 10;
private IEnumerable<RecentUsageAccountDeletions> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageAccountDeletions>();
private readonly List<TableColumn> _tableColumns = new()
{
new() { Title = "Username", PropertyName = "Username", Sortable = false },
new() { Title = "Deletions (30d)", PropertyName = "DeletionCount30d", Sortable = false },
new() { Title = "Last Deletion", PropertyName = "LastDeletionDate", Sortable = false }
};
private void HandlePageChanged(int page)
{
CurrentPage = page;
StateHasChanged();
}
}

View File

@@ -60,7 +60,7 @@
public List<RecentUsageAliases>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 20;
private int PageSize { get; set; } = 10;
private IEnumerable<RecentUsageAliases> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageAliases>();

View File

@@ -0,0 +1,72 @@
@using AliasVault.Admin.Main.Models
@using AliasVault.RazorComponents.Tables
<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 Addresses by Account Deletions (Last 30d)</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">IP addresses with the most account deletions in the last 30 days (last octet anonymized)</p>
</div>
</div>
@if (Data != null && Data.Any())
{
<div class="mb-3">
<Paginator CurrentPage="@CurrentPage" PageSize="@PageSize" TotalRecords="@Data.Count" OnPageChanged="@HandlePageChanged" />
</div>
<div class="overflow-x-auto">
<SortableTable Columns="@_tableColumns">
@foreach (var ip in PagedData)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">
<a href="logging/auth?search=@Uri.EscapeDataString(ip.OriginalIpAddress)" class="font-mono text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
@ip.IpAddress
</a>
</SortableTableColumn>
<SortableTableColumn>@ip.DeletionCount30d.ToString("N0")</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
}
else if (Data != null)
{
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No Recent Account Deletions</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No account deletions occurred in the last 30 days.</p>
</div>
}
else
{
<div class="px-6 py-8 flex justify-center">
<LoadingIndicator />
</div>
}
</div>
@code {
[Parameter]
public List<RecentUsageDeletionsByIp>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 10;
private IEnumerable<RecentUsageDeletionsByIp> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageDeletionsByIp>();
private readonly List<TableColumn> _tableColumns = new()
{
new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false },
new() { Title = "Deletions (30d)", PropertyName = "DeletionCount30d", Sortable = false }
};
private void HandlePageChanged(int page)
{
CurrentPage = page;
StateHasChanged();
}
}

View File

@@ -61,7 +61,7 @@
public List<RecentUsageEmails>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 20;
private int PageSize { get; set; } = 10;
private IEnumerable<RecentUsageEmails> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageEmails>();

View File

@@ -53,7 +53,7 @@
public List<RecentUsageMobileLogins>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 20;
private int PageSize { get; set; } = 10;
private IEnumerable<RecentUsageMobileLogins> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageMobileLogins>();

View File

@@ -53,7 +53,7 @@
public List<RecentUsageRegistrations>? Data { get; set; }
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 20;
private int PageSize { get; set; } = 10;
private IEnumerable<RecentUsageRegistrations> PagedData =>
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageRegistrations>();

View File

@@ -30,6 +30,12 @@
<!-- Top IP Addresses by Mobile Login Requests ---->
<RecentUsageMobileLoginsTable Data="@_recentUsageStats?.TopIpsByMobileLogins72h" />
<!-- Top IP Addresses by Account Deletions ---->
<RecentUsageDeletionsByIpTable Data="@_recentUsageStats?.TopIpsByDeletions30d" />
<!-- Top Usernames by Account Deletions ---->
<RecentUsageAccountDeletionsTable Data="@_recentUsageStats?.TopUsernamesByDeletions30d" />
</div>
@if (_loadingError)

View File

@@ -116,6 +116,8 @@ public class StatisticsService
GetTopUsersByEmails72hAsync().ContinueWith(t => stats.TopUsersByEmails72h = t.Result),
GetTopIpsByRegistrations72hAsync().ContinueWith(t => stats.TopIpsByRegistrations72h = t.Result),
GetTopIpsByMobileLogins72hAsync().ContinueWith(t => stats.TopIpsByMobileLogins72h = t.Result),
GetTopIpsByDeletions30dAsync().ContinueWith(t => stats.TopIpsByDeletions30d = t.Result),
GetTopUsernamesByDeletions30dAsync().ContinueWith(t => stats.TopUsernamesByDeletions30d = t.Result),
};
await Task.WhenAll(tasks);
@@ -475,7 +477,7 @@ public class StatisticsService
}
/// <summary>
/// Gets the top 20 users by number of aliases created in the last 72 hours.
/// Gets the top 100 users by number of aliases created in the last 72 hours.
/// </summary>
/// <returns>List of top users by recent aliases.</returns>
private async Task<List<RecentUsageAliases>> GetTopUsersByAliases72hAsync()
@@ -495,7 +497,7 @@ public class StatisticsService
AliasCount72h = g.Count(),
})
.OrderByDescending(u => u.AliasCount72h)
.Take(20)
.Take(100)
.ToListAsync();
return topUsers.Select(u => new RecentUsageAliases
@@ -509,7 +511,7 @@ public class StatisticsService
}
/// <summary>
/// Gets the top 20 users by number of emails received in the last 72 hours.
/// Gets the top 100 users by number of emails received in the last 72 hours.
/// </summary>
/// <returns>List of top users by recent emails.</returns>
private async Task<List<RecentUsageEmails>> GetTopUsersByEmails72hAsync()
@@ -529,7 +531,7 @@ public class StatisticsService
EmailCount72h = g.Count(),
})
.OrderByDescending(u => u.EmailCount72h)
.Take(20)
.Take(100)
.ToListAsync();
return topUsers.Select(u => new RecentUsageEmails
@@ -543,7 +545,7 @@ public class StatisticsService
}
/// <summary>
/// Gets the top 20 IP addresses by number of registrations in the last 72 hours.
/// Gets the top 100 IP addresses by number of registrations in the last 72 hours.
/// </summary>
/// <returns>List of top IP addresses by recent registrations.</returns>
private async Task<List<RecentUsageRegistrations>> GetTopIpsByRegistrations72hAsync()
@@ -565,7 +567,7 @@ public class StatisticsService
RegistrationCount72h = g.Count(),
})
.OrderByDescending(ip => ip.RegistrationCount72h)
.Take(20)
.Take(100)
.ToListAsync();
return topIps.Select(ip => new RecentUsageRegistrations
@@ -577,7 +579,7 @@ public class StatisticsService
}
/// <summary>
/// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours.
/// Gets the top 100 IP addresses by number of mobile login requests in the last 72 hours.
/// </summary>
/// <returns>List of top IP addresses by mobile login requests.</returns>
private async Task<List<RecentUsageMobileLogins>> GetTopIpsByMobileLogins72hAsync()
@@ -597,7 +599,7 @@ public class StatisticsService
MobileLoginCount72h = g.Count(),
})
.OrderByDescending(ip => ip.MobileLoginCount72h)
.Take(20)
.Take(100)
.ToListAsync();
return topIps.Select(ip => new RecentUsageMobileLogins
@@ -607,4 +609,72 @@ public class StatisticsService
MobileLoginCount72h = ip.MobileLoginCount72h,
}).ToList();
}
/// <summary>
/// Gets the top 100 IP addresses by number of account deletions in the last 30 days.
/// </summary>
/// <returns>List of top IP addresses by recent account deletions.</returns>
private async Task<List<RecentUsageDeletionsByIp>> GetTopIpsByDeletions30dAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
var cutoffDate = DateTime.UtcNow.AddDays(-30);
// Get account deletions by IP from auth logs (using AccountDeletion event type)
var topIps = await context.AuthLogs
.Where(al => al.Timestamp >= cutoffDate &&
al.IpAddress != null &&
al.IpAddress != "xxx.xxx.xxx.xxx" &&
al.IsSuccess &&
al.EventType == AuthEventType.AccountDeletion)
.GroupBy(al => al.IpAddress)
.Select(g => new
{
IpAddress = g.Key,
DeletionCount30d = g.Count(),
})
.OrderByDescending(ip => ip.DeletionCount30d)
.Take(100)
.ToListAsync();
return topIps.Select(ip => new RecentUsageDeletionsByIp
{
OriginalIpAddress = ip.IpAddress!,
IpAddress = AnonymizeIpAddress(ip.IpAddress!),
DeletionCount30d = ip.DeletionCount30d,
}).ToList();
}
/// <summary>
/// Gets the top 100 usernames by number of account deletions in the last 30 days.
/// </summary>
/// <returns>List of top usernames by recent account deletions.</returns>
private async Task<List<RecentUsageAccountDeletions>> GetTopUsernamesByDeletions30dAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
var cutoffDate = DateTime.UtcNow.AddDays(-30);
// Get account deletions by username from auth logs (using AccountDeletion event type)
var topUsernames = await context.AuthLogs
.Where(al => al.Timestamp >= cutoffDate &&
al.Username != null &&
al.IsSuccess &&
al.EventType == AuthEventType.AccountDeletion)
.GroupBy(al => al.Username)
.Select(g => new
{
Username = g.Key,
DeletionCount30d = g.Count(),
LastDeletionDate = g.Max(al => al.Timestamp),
})
.OrderByDescending(u => u.DeletionCount30d)
.Take(100)
.ToListAsync();
return topUsernames.Select(u => new RecentUsageAccountDeletions
{
Username = u.Username!,
DeletionCount30d = u.DeletionCount30d,
LastDeletionDate = u.LastDeletionDate,
}).ToList();
}
}