Files
aliasvault/apps/server/AliasVault.Admin/Main/Pages/EmailStorageStats.razor
2025-08-11 17:08:12 +02:00

426 lines
16 KiB
Plaintext

@page "/email-storage-stats"
@inherits MainBase
@using AliasVault.Admin.Services
@using AliasVault.RazorComponents
@using AliasVault.RazorComponents.Tables
@using Microsoft.EntityFrameworkCore
@inject StatisticsService StatisticsService
@inject IAliasServerDbContextFactory ContextFactory
@implements IDisposable
<PageHeader Title="Email Storage Stats" BreadcrumbItems="BreadcrumbItems">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
<div class="px-4 space-y-6">
<!-- Overview Statistics Cards -->
@if (LoadingStats)
{
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@for (int i = 0; i < 3; i++)
{
<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">
<div class="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2"></div>
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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">Total Emails</h3>
<p class="text-2xl font-bold text-gray-900 dark:text-white">@TotalEmails.ToString("N0")</p>
</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>
<p class="text-2xl font-bold text-gray-900 dark:text-white">@TotalAttachments.ToString("N0")</p>
</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">Storage Used</h3>
<p class="text-2xl font-bold text-gray-900 dark:text-white">@TotalStorageMB.ToString("N2") MB</p>
</div>
</div>
</div>
</div>
}
<!-- Email Attachments Table -->
<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">Email Attachments</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">All email attachments stored in the system. The content of the attachments is encrypted with the user's public key and are unreadable by the server.</p>
</div>
</div>
@if (LoadingAttachments)
{
<div class="p-4">
<div class="animate-pulse">
@for (int i = 0; i < 5; i++)
{
<div class="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="w-8 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="w-16 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="w-24 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div class="w-16 h-8 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
}
</div>
</div>
}
else if (Attachments?.Any() == true)
{
<div class="overflow-x-auto">
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var attachment in Attachments)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@attachment.Id</SortableTableColumn>
<SortableTableColumn>
<span class="font-medium text-gray-900 dark:text-white">@attachment.Filename</span>
<div class="text-sm text-gray-500 dark:text-gray-400">@attachment.MimeType</div>
</SortableTableColumn>
<SortableTableColumn>@FormatFileSize(attachment.Filesize)</SortableTableColumn>
<SortableTableColumn>
<span>@attachment.EmailId</span>
</SortableTableColumn>
<SortableTableColumn>
@if (!string.IsNullOrEmpty(attachment.UserEmail))
{
<a href="users/@attachment.UserId">
@attachment.UserEmail
</a>
}
else
{
<span>Unknown</span>
}
</SortableTableColumn>
<SortableTableColumn>
<button @onclick="() => DeleteAttachment(attachment.Id)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium">
Delete
</button>
</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
<div class="px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<Paginator CurrentPage="CurrentPage"
PageSize="20"
TotalRecords="TotalRecords"
OnPageChanged="HandlePageChanged" />
</div>
}
else
{
<div class="p-8 text-center">
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-gray-100 dark:bg-gray-700">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</div>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No attachments found</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No email attachments exist in the system.</p>
</div>
}
</div>
@if (ErrorMessage != null)
{
<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 Data</h3>
<p class="text-sm text-red-700 dark:text-red-300 mt-1">@ErrorMessage</p>
</div>
</div>
</div>
}
</div>
@code {
private bool LoadingStats = true;
private bool LoadingAttachments = true;
private string? ErrorMessage;
private bool _disposed = false;
private int TotalEmails = 0;
private int TotalAttachments = 0;
private double TotalStorageMB = 0;
private List<EmailAttachmentModel>? Attachments;
private int CurrentPage = 1;
private int TotalRecords = 0;
private string SortColumn { get; set; } = "Filesize";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
private readonly List<TableColumn> _tableColumns = [
new TableColumn { Title = "ID", PropertyName = "Id" },
new TableColumn { Title = "Filename", PropertyName = "Filename" },
new TableColumn { Title = "File Size", PropertyName = "Filesize" },
new TableColumn { Title = "Email ID", PropertyName = "EmailId" },
new TableColumn { Title = "User", PropertyName = "UserEmail" },
new TableColumn { Title = "Actions", Sortable = false },
];
public class EmailAttachmentModel
{
public int Id { get; set; }
public string Filename { get; set; } = string.Empty;
public string MimeType { get; set; } = string.Empty;
public int Filesize { get; set; }
public int EmailId { get; set; }
public string? UserId { get; set; }
public string? UserEmail { get; set; }
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Emails", Url = "emails" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Email Storage Stats" });
await RefreshData();
}
private void SafeStateHasChanged()
{
if (!_disposed)
{
InvokeAsync(StateHasChanged);
}
}
private async Task RefreshData()
{
if (_disposed) return;
LoadingStats = true;
LoadingAttachments = true;
ErrorMessage = null;
SafeStateHasChanged();
try
{
await using var context = await ContextFactory.CreateDbContextAsync();
// Load statistics
TotalEmails = await context.Emails.CountAsync();
TotalAttachments = await context.EmailAttachments.CountAsync();
var totalBytes = TotalAttachments > 0
? await context.EmailAttachments.SumAsync(a => (long)a.Filesize)
: 0L;
TotalStorageMB = totalBytes / (1024.0 * 1024.0);
LoadingStats = false;
SafeStateHasChanged();
// Load attachments
await LoadAttachments();
}
catch (Exception ex)
{
if (!_disposed)
{
ErrorMessage = $"Error loading data: {ex.Message}";
LoadingStats = false;
LoadingAttachments = false;
SafeStateHasChanged();
}
}
}
private async Task LoadAttachments()
{
if (_disposed) return;
LoadingAttachments = true;
SafeStateHasChanged();
try
{
await using var context = await ContextFactory.CreateDbContextAsync();
TotalRecords = await context.EmailAttachments.CountAsync();
var skip = (CurrentPage - 1) * 20;
// Apply sorting and pagination
var sortedQuery = ApplySort(context.EmailAttachments);
var attachmentData = await sortedQuery
.Skip(skip)
.Take(20)
.ToListAsync();
// Then map to view models to avoid disposed context issues
if (!_disposed)
{
Attachments = attachmentData.Select(a => new EmailAttachmentModel
{
Id = a.Id,
Filename = a.Filename,
MimeType = a.MimeType,
Filesize = a.Filesize,
EmailId = a.EmailId,
UserId = a.Email?.EncryptionKey?.UserId,
UserEmail = a.Email?.EncryptionKey?.User?.UserName
}).ToList();
}
}
catch (Exception ex)
{
if (!_disposed)
{
ErrorMessage = $"Error loading attachments: {ex.Message}";
}
}
finally
{
if (!_disposed)
{
LoadingAttachments = false;
SafeStateHasChanged();
}
}
}
private async Task HandlePageChanged(int newPage)
{
CurrentPage = newPage;
await LoadAttachments();
}
private async Task HandleSortChanged((string column, SortDirection direction) sort)
{
SortColumn = sort.column;
SortDirection = sort.direction;
CurrentPage = 1; // Reset to first page when sorting changes
await LoadAttachments();
}
private IQueryable<EmailAttachment> ApplySort(IQueryable<EmailAttachment> query)
{
// Always include navigation properties for all sorting scenarios
query = query.Include(a => a.Email)
.ThenInclude(e => e.EncryptionKey)
.ThenInclude(k => k.User);
switch (SortColumn)
{
case "Id":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Id)
: query.OrderByDescending(x => x.Id);
break;
case "Filename":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Filename)
: query.OrderByDescending(x => x.Filename);
break;
case "Filesize":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Filesize)
: query.OrderByDescending(x => x.Filesize);
break;
case "EmailId":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.EmailId)
: query.OrderByDescending(x => x.EmailId);
break;
case "UserEmail":
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => x.Email.EncryptionKey.User.Email)
: query.OrderByDescending(x => x.Email.EncryptionKey.User.Email);
break;
default:
query = query.OrderByDescending(x => x.Filesize);
break;
}
return query;
}
private async Task DeleteAttachment(int attachmentId)
{
var confirmed = await ConfirmModalService.ShowConfirmation(
"Delete Attachment",
"Are you sure you want to delete this attachment? This action cannot be undone.",
"Delete",
"Cancel");
if (!confirmed) return;
try
{
await using var context = await ContextFactory.CreateDbContextAsync();
var attachment = await context.EmailAttachments.FindAsync(attachmentId);
if (attachment != null)
{
context.EmailAttachments.Remove(attachment);
await context.SaveChangesAsync();
// Refresh data after deletion
await RefreshData();
}
}
catch (Exception ex)
{
if (!_disposed)
{
ErrorMessage = $"Error deleting attachment: {ex.Message}";
SafeStateHasChanged();
}
}
}
private static string FormatFileSize(int bytes)
{
if (bytes < 1024)
return $"{bytes} B";
else if (bytes < 1024 * 1024)
return $"{bytes / 1024.0:F1} KB";
else
return $"{bytes / (1024.0 * 1024.0):F1} MB";
}
public void Dispose()
{
_disposed = true;
}
}