diff --git a/apps/server/AliasVault.Client/Main/Layout/TopMenu.razor b/apps/server/AliasVault.Client/Main/Layout/TopMenu.razor index 41e929304..e7302f711 100644 --- a/apps/server/AliasVault.Client/Main/Layout/TopMenu.razor +++ b/apps/server/AliasVault.Client/Main/Layout/TopMenu.razor @@ -69,6 +69,11 @@ @Localizer["SecuritySettingsNav"] +
  • + + @Localizer["StorageInsightsNav"] + +
  • @Localizer["ImportExportNav"] diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/StorageInsights.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/StorageInsights.razor new file mode 100644 index 000000000..205f8b881 --- /dev/null +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/StorageInsights.razor @@ -0,0 +1,371 @@ +@page "/settings/storage-insights" +@inherits MainBase +@using AliasVault.RazorComponents.Tables +@using Microsoft.EntityFrameworkCore +@using Microsoft.Extensions.Localization + +@Localizer["PageTitle"] + + + + +@if (IsLoading) +{ +
    +

    @SharedLocalizer["Loading"]

    +
    +} +else +{ +
    +

    @Localizer["EstimatedTotalTitle"]

    +

    @FormatSize(_estimatedTotalBytes)

    +

    @Localizer["EstimatedTotalDescription"]

    +
    + +
    +

    @Localizer["CountsTitle"]

    +
    +
    +

    @Localizer["ItemCountLabel"]

    +

    @_totalItems

    +
    +
    +

    @Localizer["ItemsWithAttachmentsLabel"]

    +

    @_itemsWithAttachments

    +
    +
    +

    @Localizer["ItemsWithLogosLabel"]

    +

    @_itemsWithLogos

    +
    +
    +
    + +
    +

    @Localizer["BreakdownTitle"]

    +

    @Localizer["BreakdownDescription"]

    + + @if (_estimatedTotalBytes == 0) + { +

    -

    + } + else + { +
    +
    +
    +
    +
    +
    +
    +
    + + @Localizer["BreakdownBaseOverheadLabel"]: + @FormatSize(_baseOverheadBytes) (@Percent(_baseOverheadBytes, _estimatedTotalBytes).ToString("F1")%) +
    +
    + + @Localizer["BreakdownCredentialsLabel"]: + @FormatSize(_credentialBytes) (@Percent(_credentialBytes, _estimatedTotalBytes).ToString("F1")%) +
    +
    + + @Localizer["BreakdownAttachmentsLabel"]: + @FormatSize(_attachmentBytesWithOverhead) (@Percent(_attachmentBytesWithOverhead, _estimatedTotalBytes).ToString("F1")%) +
    +
    + + @Localizer["BreakdownLogosLabel"]: + @FormatSize(_logoBytesWithOverhead) (@Percent(_logoBytesWithOverhead, _estimatedTotalBytes).ToString("F1")%) +
    +
    + } +
    + +
    +

    @Localizer["TopAttachmentsTitle"]

    +

    @Localizer["TopAttachmentsDescription"]

    + + @if (_topAttachments.Count == 0) + { +

    -

    + } + else + { + + @foreach (var attachment in _topAttachments) + { + + @attachment.Filename + @FormatSize(attachment.SizeBytes) + @(string.IsNullOrWhiteSpace(attachment.ItemName) ? "-" : attachment.ItemName) + @attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd") + + } + + } +
    + +
    +

    @Localizer["TopLogosTitle"]

    +

    @Localizer["TopLogosDescription"]

    + + @if (_topLogos.Count == 0) + { +

    -

    + } + else + { + + @foreach (var logo in _topLogos) + { + + @logo.Source + @FormatSize(logo.SizeBytes) + @(string.IsNullOrWhiteSpace(logo.MimeType) ? "-" : logo.MimeType) + @logo.ItemCount @Localizer["ItemsUsingLogoSuffix"] + + } + + } +
    +} + +@code { + // Approximate per-row SQLite overhead in bytes. SQLite uses 4 KiB pages by default; + // small rows pack many per page, while large blobs spill into overflow pages. These + // constants cover row headers and the indexed columns AliasClientDbContext defines. + // They are intentionally rough — attachments and logos dominate real vault size. + private const int PerItemOverheadBytes = 200; + private const int PerFieldValueOverheadBytes = 80; + private const int PerAttachmentOverheadBytes = 120; + private const int PerLogoOverheadBytes = 150; + private const int PerTotpOverheadBytes = 100; + private const int PerPasskeyOverheadBytes = 200; + + // Empty AliasVault SQLite file is ~200 KB on disk: file header, page allocation + // for each table/index, sqlite_master schema rows, and __EFMigrationsHistory. + // Constant rather than measured because we don't have access to the file size + // here and the value is stable across vaults of the same schema version. + private const int BaseSqliteOverheadBytes = 200 * 1024; + + private readonly List _attachmentColumns = + [ + new TableColumn { Title = "Filename", Sortable = false }, + new TableColumn { Title = "Size", Sortable = false }, + new TableColumn { Title = "Item", Sortable = false }, + new TableColumn { Title = "Created", Sortable = false }, + ]; + + private readonly List _logoColumns = + [ + new TableColumn { Title = "Source", Sortable = false }, + new TableColumn { Title = "Size", Sortable = false }, + new TableColumn { Title = "Type", Sortable = false }, + new TableColumn { Title = "Used by", Sortable = false }, + ]; + + private bool IsLoading { get; set; } = true; + + private int _totalItems; + private int _itemsWithAttachments; + private int _itemsWithLogos; + private long _credentialBytes; + private long _attachmentBytesWithOverhead; + private long _logoBytesWithOverhead; + private long _baseOverheadBytes; + private long _estimatedTotalBytes; + private List _topAttachments = []; + private List _topLogos = []; + + private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Settings.StorageInsights", "AliasVault.Client"); + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["BreadcrumbTitle"] }); + + // Localize the table column headers (created at field init time before localizer was available). + _attachmentColumns[0].Title = Localizer["ColumnFilename"]; + _attachmentColumns[1].Title = Localizer["ColumnSize"]; + _attachmentColumns[2].Title = Localizer["ColumnItem"]; + _attachmentColumns[3].Title = Localizer["ColumnCreated"]; + _logoColumns[0].Title = Localizer["ColumnWebsiteURL"]; + _logoColumns[1].Title = Localizer["ColumnSize"]; + _logoColumns[2].Title = Localizer["ColumnMimeType"]; + _logoColumns[3].Title = Localizer["ColumnItemCount"]; + + await LoadStatisticsAsync(); + } + + private static string FormatSize(long bytes) + { + const double Kib = 1024d; + const double Mib = Kib * 1024d; + + if (bytes < Kib) + { + return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{bytes} B"); + } + + if (bytes < Mib) + { + return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{(bytes / Kib):F1} KB"); + } + + return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{(bytes / Mib):F1} MB"); + } + + private static double Percent(long part, long total) + { + return total == 0 ? 0d : Math.Round((double)part / total * 100d, 2); + } + + private async Task LoadStatisticsAsync() + { + var ctx = await DbService.GetDbContextAsync(); + + _totalItems = await ctx.Items.CountAsync(i => !i.IsDeleted && i.DeletedAt == null); + _itemsWithAttachments = await ctx.Attachments + .Where(a => !a.IsDeleted) + .Select(a => a.ItemId) + .Distinct() + .CountAsync(); + _itemsWithLogos = await ctx.Items.CountAsync(i => !i.IsDeleted && i.DeletedAt == null && i.LogoId != null); + + var attachmentRows = await ctx.Attachments + .Where(a => !a.IsDeleted) + .Select(a => new AttachmentRow + { + Id = a.Id, + Filename = a.Filename, + SizeBytes = a.Blob.Length, + ItemId = a.ItemId, + ItemName = a.Item.Name, + CreatedAt = a.CreatedAt, + }) + .ToListAsync(); + + var logoProjections = await ctx.Logos + .Where(l => !l.IsDeleted && l.FileData != null) + .Select(l => new + { + l.Id, + l.Source, + l.MimeType, + SizeBytes = l.FileData!.Length, + ItemIds = l.Items + .Where(i => !i.IsDeleted && i.DeletedAt == null) + .Select(i => i.Id) + .ToList(), + }) + .ToListAsync(); + + var logoRows = logoProjections + .Select(l => new LogoRow + { + Id = l.Id, + Source = l.Source, + MimeType = l.MimeType, + SizeBytes = l.SizeBytes, + ItemCount = l.ItemIds.Count, + FirstItemId = l.ItemIds.Count > 0 ? l.ItemIds[0] : null, + }) + .ToList(); + + var fieldValueLengths = await ctx.FieldValues + .Where(fv => !fv.IsDeleted) + .Select(fv => fv.Value == null ? 0 : fv.Value.Length) + .ToListAsync(); + var fieldValueBytes = fieldValueLengths.Sum(x => (long)x); + var fieldValueCount = fieldValueLengths.Count; + + var passkeySizes = await ctx.Passkeys + .Where(p => !p.IsDeleted) + .Select(p => new + { + UserHandleLen = p.UserHandle.Length, + PrfKeyLen = p.PrfKey == null ? 0 : p.PrfKey.Length, + AdditionalDataLen = p.AdditionalData == null ? 0 : p.AdditionalData.Length, + PublicKeyLen = p.PublicKey.Length, + PrivateKeyLen = p.PrivateKey.Length, + }) + .ToListAsync(); + var passkeyBytes = passkeySizes.Sum(p => (long)(p.UserHandleLen + p.PrfKeyLen + p.AdditionalDataLen + p.PublicKeyLen + p.PrivateKeyLen)); + var passkeyCount = passkeySizes.Count; + + var totpCount = await ctx.TotpCodes.CountAsync(t => !t.IsDeleted); + + var attachmentBytes = attachmentRows.Sum(a => (long)a.SizeBytes); + var logoBytes = logoRows.Sum(l => (long)l.SizeBytes); + + _attachmentBytesWithOverhead = attachmentBytes + ((long)attachmentRows.Count * PerAttachmentOverheadBytes); + _logoBytesWithOverhead = logoBytes + ((long)logoRows.Count * PerLogoOverheadBytes); + + _credentialBytes = fieldValueBytes + + passkeyBytes + + ((long)_totalItems * PerItemOverheadBytes) + + ((long)fieldValueCount * PerFieldValueOverheadBytes) + + ((long)passkeyCount * PerPasskeyOverheadBytes) + + ((long)totpCount * PerTotpOverheadBytes); + + _baseOverheadBytes = BaseSqliteOverheadBytes; + + _estimatedTotalBytes = _credentialBytes + + _attachmentBytesWithOverhead + + _logoBytesWithOverhead + + _baseOverheadBytes; + + _topAttachments = attachmentRows + .OrderByDescending(a => a.SizeBytes) + .Take(10) + .ToList(); + + _topLogos = logoRows + .OrderByDescending(l => l.SizeBytes) + .Take(10) + .ToList(); + + IsLoading = false; + StateHasChanged(); + } + + private void NavigateToItem(Guid itemId) + { + NavigationManager.NavigateTo($"/items/{itemId}"); + } + + private sealed class AttachmentRow + { + public Guid Id { get; init; } + + public string Filename { get; init; } = string.Empty; + + public int SizeBytes { get; init; } + + public Guid ItemId { get; init; } + + public string? ItemName { get; init; } + + public DateTime CreatedAt { get; init; } + } + + private sealed class LogoRow + { + public Guid Id { get; init; } + + public string Source { get; init; } = string.Empty; + + public string? MimeType { get; init; } + + public int SizeBytes { get; init; } + + public int ItemCount { get; init; } + + public Guid? FirstItemId { get; init; } + } +} diff --git a/apps/server/AliasVault.Client/Resources/Layout/TopMenu.en.resx b/apps/server/AliasVault.Client/Resources/Layout/TopMenu.en.resx index 091b2e8b6..e19e7858c 100644 --- a/apps/server/AliasVault.Client/Resources/Layout/TopMenu.en.resx +++ b/apps/server/AliasVault.Client/Resources/Layout/TopMenu.en.resx @@ -39,6 +39,10 @@ Security settings Navigation link for security settings + + Storage insights + Navigation link for vault storage insights page + Import / Export Navigation link for import/export settings diff --git a/apps/server/AliasVault.Client/Resources/Pages/Main/Settings/StorageInsights.en.resx b/apps/server/AliasVault.Client/Resources/Pages/Main/Settings/StorageInsights.en.resx new file mode 100644 index 000000000..c0f8891fa --- /dev/null +++ b/apps/server/AliasVault.Client/Resources/Pages/Main/Settings/StorageInsights.en.resx @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Vault storage insights + Page title for vault storage insights + + + See an estimate of your vault size. For best sync performance, it's advised to keep your vault size small and delete items you no longer need. + Page description for vault storage insights + + + Storage insights + Breadcrumb title for storage insights page + + + + + Estimated vault size + Title for the estimated total vault size card + + + This is an approximation calculated from your local data. + Subtext explaining that the estimate is approximate + + + + + Overview + Title for the counts/overview section + + + Items + Label for total item count card + + + Items with attachments + Label for count of items that have at least one attachment + + + Items with a logo + Label for count of items that reference a logo + + + + + Storage usage breakdown + Title for the breakdown bar section + + + Approximate share of vault size by category. Attachments and logos usually take up most of the space. + Description for the breakdown bar + + + Credentials + Legend label for credential data slice + + + Attachments + Legend label for attachments slice + + + Logos + Legend label for logos slice + + + Database overhead + Legend label for the fixed SQLite base/schema overhead slice + + + + + Largest attachments + Title for the top attachments table + + + Top 10 largest file attachments. Remove the attachment if you no longer need it. + Description for the top attachments table + + + + + Largest logos + Title for the top logos table + + + Top 10 largest service logos. Logos are reused for items with the same domain. + Description for the top logos table + + + + + Filename + Table column header for attachment filename + + + Size + Table column header for size in KB + + + Item + Table column header for the parent item name + + + Created + Table column header for creation date + + + Website URL + Table column header for logo source domain + + + Type + Table column header for MIME type + + + Used by + Table column header for the number of items using a logo + + + + + items + Suffix shown after the count of items using a given logo (e.g. "3 items") + + diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css index 4a8ad1a82..2cdcda33b 100644 --- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css @@ -1638,6 +1638,10 @@ video { border-radius: 0.375rem; } +.rounded-sm { + border-radius: 0.125rem; +} + .rounded-b-lg { border-bottom-right-radius: 0.5rem; border-bottom-left-radius: 0.5rem; @@ -1809,6 +1813,11 @@ video { border-color: rgb(254 240 138 / var(--tw-border-opacity)); } +.border-yellow-400 { + --tw-border-opacity: 1; + border-color: rgb(250 204 21 / var(--tw-border-opacity)); +} + .bg-amber-100 { --tw-bg-opacity: 1; background-color: rgb(254 243 199 / var(--tw-bg-opacity)); @@ -2018,6 +2027,21 @@ video { background-color: rgb(234 179 8 / var(--tw-bg-opacity)); } +.bg-amber-500 { + --tw-bg-opacity: 1; + background-color: rgb(245 158 11 / var(--tw-bg-opacity)); +} + +.bg-emerald-500 { + --tw-bg-opacity: 1; + background-color: rgb(16 185 129 / var(--tw-bg-opacity)); +} + +.bg-yellow-200 { + --tw-bg-opacity: 1; + background-color: rgb(254 240 138 / var(--tw-bg-opacity)); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -3294,6 +3318,11 @@ video { border-color: rgb(133 77 14 / var(--tw-border-opacity)); } +.dark\:border-yellow-600:is(.dark *) { + --tw-border-opacity: 1; + border-color: rgb(202 138 4 / var(--tw-border-opacity)); +} + .dark\:bg-amber-800\/30:is(.dark *) { background-color: rgb(146 64 14 / 0.3); } @@ -4269,6 +4298,10 @@ video { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .lg\:flex-row { flex-direction: row; }