5 Commits
Author SHA1 Message Date
REDCODE d3124c55bd feat(stats): add charts to visualize distributions
Adds Chart.js visualizations for the stats distributions alongside the
existing tables:

- Vendors Chart.js (4.4.7) into wwwroot/lib/js/ and adds a small
  statsCharts.js interop wrapper with byte formatting for the storage
  chart's y-axis and tooltips.
- New StatChart shared component (canvas + IJSRuntime lifecycle,
  following the existing ProfileCropper interop pattern).
- Stats page charts: storage by type (bar), file formats (doughnut),
  resolution distribution (bar), monthly growth (line).

Refs #177
2026-08-17 22:58:32 +02:00
REDCODE c97b16ada9 refactor(stats): extract shared components & formatting helper
Breaks up the monolithic Stats.razor into reusable pieces under
Components/Shared/ per the AGENTS.md extraction rule:

- StatCountCard: covers the three count-card shapes used 19 times
  (solid top-level, outline data-completeness, icon-title sections).
- StatTable: reusable card+table scaffold for the seven distribution
  tables, with optional header/footer row fragments.
- Formatting: static FormatBytes / PercentOfTotal helpers (were private
  methods in Stats.razor).

Stats.razor now composes these; visual output is unchanged.

Refs #176
2026-08-17 22:55:54 +02:00
REDCODE 5e7c053218 feat(stats): cache aggregated results in-memory
Caches the assembled StatsDto in IMemoryCache with a 60s TTL. The cache
key is fingerprinted with the ThumbnailSize/PreviewSize setting values so
changing those settings immediately invalidates the stale counts on the
next request. Registers AddMemoryCache() in Program.cs.

Refs #175
2026-08-17 22:53:24 +02:00
REDCODE e1bb7d3330 refactor(stats): consolidate and async-ify stats queries
Rewrites StatsRepository to be fully async and collapses the ~30
synchronous round-trips into a bounded set of aggregated queries:

- Single grouped pass over non-deleted assets computes totals, storage,
  visibility breakdown, orphans, 7/30-day activity, and all
  data-completeness counts (missing metadata/thumbnail/preview/phash).
- AssetsByType + StorageByType come from one GROUP BY on Type.
- UsersByAccessLevel, TotalUsers, and users registered in 30 days come
  from one GROUP BY on AccessLevel.
- MIME breakdown and resolution buckets are now GROUP BY'd in SQL; the
  resolution bucket key is a translatable CASE expression on the max
  dimension, so no more loading every asset's dimensions into memory.
- AssetsWithNoAlbum / AssetsWithNoPerson use set-based NOT IN anti-joins
  over the Album.Assets navigation instead of per-row NOT EXISTS.
- TopTags uses the Tag.Assets navigation (join table indexed on TagsId).
- Drops IDisposable/Dispose() so the transient repo no longer disposes
  the shared scoped DbContext.
- StatsController action is now async.

StatsDto shape is unchanged, so the existing .http tests remain valid.

Refs #173
2026-08-17 22:52:27 +02:00
REDCODE 0c8e653a5f perf(stats): add filtered database indexes
Adds partial indexes so stats count queries are index scans instead of
full table scans over 1M+ assets:
- IX_Assets_Stats_CreatedAt: (CreatedAt) WHERE DeletedAt IS NULL
- IX_Assets_Stats_Type: (Type) WHERE DeletedAt IS NULL
- IX_Assets_Stats_MissingThumbnail: (ThumbnailPath) WHERE ThumbnailPath = '' AND DeletedAt IS NULL
- IX_Assets_Stats_MissingPreview: (PreviewPath) WHERE PreviewPath = '' AND DeletedAt IS NULL
- IX_Assets_Stats_MissingPhash: (Hash) WHERE Hash = zero bit(64) AND DeletedAt IS NULL
- IX_Assets_Stats_Resolution: (ResolutionWidth, ResolutionHeight, Type) WHERE DeletedAt IS NULL

The orphan-asset count (FolderId IS NULL) reuses the existing
IX_Assets_FolderId index; a separate filtered index on the same column is
not supported by EF's index model.

Refs #174
2026-08-17 22:50:35 +02:00
16 changed files with 1354 additions and 262 deletions
+19
View File
@@ -86,6 +86,25 @@ public class LactoseDbContext : DbContext {
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// Stats-supporting partial indexes
modelBuilder.Entity<Asset>().HasIndex(e => e.CreatedAt)
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.Type)
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.ThumbnailPath)
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.PreviewPath)
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.Hash)
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => new { e.ResolutionWidth, e.ResolutionHeight, e.Type })
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
// People
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
.HasDatabaseName("IX_People_Name_Trgm")
+3 -2
View File
@@ -20,11 +20,12 @@ public class StatsController(
/// <summary>
/// Returns comprehensive aggregate statistics about the instance.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
[HttpGet]
public ActionResult<StatsDto> Get() {
public async Task<ActionResult<StatsDto>> Get(CancellationToken cancellationToken) {
logger.LogTrace("Stats requested");
var stats = statsRepository.GetStats();
var stats = await statsRepository.GetStatsAsync(cancellationToken);
return Ok(stats);
}
}
@@ -0,0 +1,625 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260817205017_OptimizeStatsIndexes")]
partial class OptimizeStatsIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class OptimizeStatsIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_CreatedAt",
table: "Assets",
column: "CreatedAt",
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_MissingPhash",
table: "Assets",
column: "Hash",
filter: "\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_MissingPreview",
table: "Assets",
column: "PreviewPath",
filter: "\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_MissingThumbnail",
table: "Assets",
column: "ThumbnailPath",
filter: "\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_Resolution",
table: "Assets",
columns: new[] { "ResolutionWidth", "ResolutionHeight", "Type" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_Type",
table: "Assets",
column: "Type",
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_CreatedAt",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_MissingPhash",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_MissingPreview",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_MissingThumbnail",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_Resolution",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_Type",
table: "Assets");
}
}
}
@@ -177,8 +177,16 @@ namespace Lactose.Migrations
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
@@ -188,6 +196,18 @@ namespace Lactose.Migrations
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
@@ -198,6 +218,10 @@ namespace Lactose.Migrations
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
+1
View File
@@ -167,6 +167,7 @@ builder.Services.AddSingleton<JobManager>();
builder.Services.AddSingleton<JobScheduler>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddSwaggerGen(
options => {
+4 -3
View File
@@ -5,10 +5,11 @@ namespace Lactose.Repositories;
/// <summary>
/// Repository for gathering aggregate statistics from the database.
/// </summary>
public interface IStatsRepository : IDisposable {
public interface IStatsRepository {
/// <summary>
/// Gathers all aggregate statistics into a single DTO.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A fully populated <see cref="StatsDto"/>.</returns>
StatsDto GetStats();
}
Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken);
}
+138 -128
View File
@@ -3,150 +3,166 @@ using Butter.Settings;
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Microsoft.Extensions.Caching.Memory;
using System.Collections;
namespace Lactose.Repositories;
/// <inheritdoc />
public class StatsRepository(LactoseDbContext context, ISettingsRepository settingsRepo) : IStatsRepository {
public class StatsRepository(
LactoseDbContext context,
ISettingsRepository settingsRepo,
IMemoryCache cache
) : IStatsRepository {
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
/// <inheritdoc />
public StatsDto GetStats() {
var dto = new StatsDto();
var now = DateTime.UtcNow;
dto.TotalAssets = context.Assets.Count(a => a.DeletedAt == null);
dto.TotalUsers = context.Users.Count(u => u.DeletedAt == null);
dto.TotalAlbums = context.Albums.Count();
dto.TotalTags = context.Tags.Count();
dto.TotalPeople = context.People.Count();
dto.TotalFaces = context.Faces.Count();
dto.TotalFolders = context.Folders.Count();
dto.AssetsByType = context.Assets
.Where(a => a.DeletedAt == null)
.GroupBy(a => a.Type)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionary(x => x.Key, x => x.Count);
dto.TotalStorageBytes = context.Assets
.Where(a => a.DeletedAt == null)
.Sum(a => (long?)a.FileSize) ?? 0;
dto.StorageByType = context.Assets
.Where(a => a.DeletedAt == null)
.GroupBy(a => a.Type)
.Select(g => new { g.Key, Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
.ToDictionary(x => x.Key, x => x.Size);
dto.UsersByAccessLevel = context.Users
.Where(u => u.DeletedAt == null)
.GroupBy(u => u.AccessLevel)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionary(x => x.Key, x => x.Count);
dto.AssetsAddedLast7Days = context.Assets
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-7));
dto.AssetsAddedLast30Days = context.Assets
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-30));
dto.UsersRegisteredLast30Days = context.Users
.Count(u => u.DeletedAt == null && u.CreatedAt >= now.AddDays(-30));
dto.OrphanAssets = context.Assets.Count(a => a.DeletedAt == null && a.FolderId == null);
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Public);
dto.ProtectedAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Protected);
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Private);
dto.AssetsMissingMetadata = context.Assets.Count(a =>
a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
public async Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken) {
var thumbnailSizeSetting = settingsRepo.Get(Settings.ThumbnailSize.AsString());
var previewSizeSetting = settingsRepo.Get(Settings.PreviewSize.AsString());
var cacheKey = $"stats:{thumbnailSizeSetting?.Value}:{previewSizeSetting?.Value}";
if (cache.TryGetValue(cacheKey, out StatsDto? cached) && cached is not null)
return cached;
_ = int.TryParse(thumbnailSizeSetting?.Value, out var expectedThumbnailSize);
_ = int.TryParse(previewSizeSetting?.Value, out var expectedPreviewSize);
dto.AssetsMissingThumbnail = context.Assets.Count(a =>
(a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
var stats = await ComputeStatsAsync(cancellationToken, expectedThumbnailSize, expectedPreviewSize);
cache.Set(cacheKey, stats, CacheTtl);
return stats;
}
dto.AssetsMissingThumbnailStale = expectedThumbnailSize > 0
? context.Assets.Count(a =>
a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null)
: 0;
dto.AssetsMissingPreviews = context.Assets.Count(a =>
(a.PreviewPath == null || a.PreviewPath == "") && a.DeletedAt == null);
dto.AssetsMissingPreviewsStale = expectedPreviewSize > 0
? context.Assets.Count(a =>
a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null)
: 0;
private async Task<StatsDto> ComputeStatsAsync(
CancellationToken cancellationToken,
int expectedThumbnailSize,
int expectedPreviewSize) {
var dto = new StatsDto();
var now = DateTime.UtcNow;
var emptyHash = new BitArray(64);
dto.AssetsMissingPhash = context.Assets.Count(a =>
a.Hash == emptyHash && a.DeletedAt == null);
var albumAssetSet = context.Set<Dictionary<string, object>>("AlbumAsset");
dto.AssetsWithNoAlbum = context.Assets.Count(a =>
a.DeletedAt == null && !albumAssetSet.Any(aa => EF.Property<Guid?>(aa, "AssetsId") == a.Id));
dto.AssetsWithNoPerson = context.Assets.Count(a =>
a.DeletedAt == null && !albumAssetSet.Any(aa =>
EF.Property<Guid?>(aa, "AssetsId") == a.Id
&& context.Albums.Any(al => al.Id == EF.Property<Guid?>(aa, "AlbumsId") && al.PersonOwnerId != null)));
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
dto.CosplayersMissingProfile = context.People.Count(p => p.ProfileAssetId == null);
dto.TopTags = (
from at in context.Set<Dictionary<string, object>>("AssetTag")
join a in context.Assets.Where(a => a.DeletedAt == null)
on EF.Property<Guid?>(at, "AssetsId") equals (Guid?)a.Id
join t in context.Tags
on EF.Property<Guid?>(at, "TagsId") equals (Guid?)t.Id
group t by new { t.Id, t.Name } into g
select new TagStatDto {
Id = g.Key.Id,
Name = g.Key.Name,
AssetCount = g.Count()
}
).OrderByDescending(t => t.AssetCount).Take(10).ToList();
var resolutions = context.Assets
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
.Select(a => new { a.ResolutionWidth, a.ResolutionHeight })
.ToList();
dto.ResolutionDistribution = resolutions
.GroupBy(r => ResolveBucket(r.ResolutionWidth, r.ResolutionHeight))
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Count() })
.OrderBy(r => r.Count)
.ToList();
var mimeTypes = context.Assets
var assetStats = await context.Assets
.Where(a => a.DeletedAt == null)
.Select(a => a.MimeType)
.ToList();
.GroupBy(a => 1)
.Select(g => new {
Total = g.Count(),
TotalStorage = g.Sum(a => (long?)a.FileSize) ?? 0,
Public = g.Count(a => a.Visibility == EVisibility.Public),
Protected = g.Count(a => a.Visibility == EVisibility.Protected),
Private = g.Count(a => a.Visibility == EVisibility.Private),
Orphan = g.Count(a => a.FolderId == null),
Added7d = g.Count(a => a.CreatedAt >= now.AddDays(-7)),
Added30d = g.Count(a => a.CreatedAt >= now.AddDays(-30)),
MissingMetadata = g.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0),
MissingThumbnail = g.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == ""),
MissingThumbnailStale = expectedThumbnailSize > 0
? g.Count(a => a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize)
: 0,
MissingPreview = g.Count(a => a.PreviewPath == null || a.PreviewPath == ""),
MissingPreviewStale = expectedPreviewSize > 0
? g.Count(a => a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize)
: 0,
MissingPhash = g.Count(a => a.Hash == emptyHash)
})
.FirstOrDefaultAsync(cancellationToken);
dto.FileFormatBreakdown = mimeTypes
.GroupBy(m => m)
if (assetStats is not null) {
dto.TotalAssets = assetStats.Total;
dto.TotalStorageBytes = assetStats.TotalStorage;
dto.PublicAssets = assetStats.Public;
dto.ProtectedAssets = assetStats.Protected;
dto.PrivateAssets = assetStats.Private;
dto.OrphanAssets = assetStats.Orphan;
dto.AssetsAddedLast7Days = assetStats.Added7d;
dto.AssetsAddedLast30Days = assetStats.Added30d;
dto.AssetsMissingMetadata = assetStats.MissingMetadata;
dto.AssetsMissingThumbnail = assetStats.MissingThumbnail;
dto.AssetsMissingThumbnailStale = assetStats.MissingThumbnailStale;
dto.AssetsMissingPreviews = assetStats.MissingPreview;
dto.AssetsMissingPreviewsStale = assetStats.MissingPreviewStale;
dto.AssetsMissingPhash = assetStats.MissingPhash;
}
var byType = await context.Assets
.Where(a => a.DeletedAt == null)
.GroupBy(a => a.Type)
.Select(g => new { g.Key, Count = g.Count(), Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
.ToListAsync(cancellationToken);
dto.AssetsByType = byType.ToDictionary(x => x.Key, x => x.Count);
dto.StorageByType = byType.ToDictionary(x => x.Key, x => x.Size);
var userStats = await context.Users
.Where(u => u.DeletedAt == null)
.GroupBy(u => u.AccessLevel)
.Select(g => new { g.Key, Count = g.Count(), Registered30 = g.Count(u => u.CreatedAt >= now.AddDays(-30)) })
.ToListAsync(cancellationToken);
dto.UsersByAccessLevel = userStats.ToDictionary(x => x.Key, x => x.Count);
dto.TotalUsers = userStats.Sum(x => x.Count);
dto.UsersRegisteredLast30Days = userStats.Sum(x => x.Registered30);
dto.TotalAlbums = await context.Albums.CountAsync(cancellationToken);
dto.TotalTags = await context.Tags.CountAsync(cancellationToken);
dto.TotalPeople = await context.People.CountAsync(cancellationToken);
dto.TotalFaces = await context.Faces.CountAsync(cancellationToken);
dto.TotalFolders = await context.Folders.CountAsync(cancellationToken);
dto.AlbumsMissingCover = await context.Albums.CountAsync(a => a.CoverAssetId == null, cancellationToken);
dto.CosplayersMissingProfile = await context.People.CountAsync(p => p.ProfileAssetId == null, cancellationToken);
var albumAssetIds = context.Albums.SelectMany(al => al.Assets!).Select(a => a.Id).Distinct();
dto.AssetsWithNoAlbum = await context.Assets.CountAsync(
a => a.DeletedAt == null && !albumAssetIds.Contains(a.Id), cancellationToken);
var personAlbumAssetIds = context.Albums
.Where(al => al.PersonOwnerId != null)
.SelectMany(al => al.Assets!)
.Select(a => a.Id)
.Distinct();
dto.AssetsWithNoPerson = await context.Assets.CountAsync(
a => a.DeletedAt == null && !personAlbumAssetIds.Contains(a.Id), cancellationToken);
dto.TopTags = await context.Tags
.Select(t => new TagStatDto {
Id = t.Id,
Name = t.Name,
AssetCount = t.Assets!.Count(a => a.DeletedAt == null)
})
.OrderByDescending(t => t.AssetCount)
.Take(10)
.ToListAsync(cancellationToken);
dto.FileFormatBreakdown = await context.Assets
.Where(a => a.DeletedAt == null)
.GroupBy(a => a.MimeType)
.Select(g => new MimeTypeStatDto { MimeType = g.Key, Count = g.Count() })
.OrderByDescending(m => m.Count)
.ToListAsync(cancellationToken);
var maxDims = await context.Assets
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
.GroupBy(a => a.ResolutionWidth > a.ResolutionHeight ? a.ResolutionWidth : a.ResolutionHeight)
.Select(g => new { MaxDim = g.Key, Count = g.Count() })
.ToListAsync(cancellationToken);
dto.ResolutionDistribution = maxDims
.GroupBy(x => ResolveBucket(x.MaxDim))
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Sum(x => x.Count) })
.OrderBy(r => r.Count)
.ToList();
var twelveMonthsAgo = now.AddMonths(-12);
var monthlyAssets = context.Assets
var monthlyAssets = await context.Assets
.Where(a => a.DeletedAt == null && a.CreatedAt >= twelveMonthsAgo)
.GroupBy(a => new { a.CreatedAt.Year, a.CreatedAt.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
.ToList();
.ToListAsync(cancellationToken);
var monthlyUsers = context.Users
var monthlyUsers = await context.Users
.Where(u => u.CreatedAt >= twelveMonthsAgo)
.GroupBy(u => new { u.CreatedAt.Year, u.CreatedAt.Month })
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
.ToList();
.ToListAsync(cancellationToken);
var months = Enumerable.Range(0, 12)
.Select(i => twelveMonthsAgo.AddMonths(i + 1))
@@ -163,17 +179,11 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
return dto;
}
/// <inheritdoc />
public void Dispose() => context.Dispose();
private static string ResolveBucket(int width, int height) {
int maxDim = Math.Max(width, height);
return maxDim switch {
<= 480 => "SD (≤480p)",
<= 720 => "HD (≤720p)",
<= 1080 => "Full HD (≤1080p)",
<= 2160 => "4K (≤2160p)",
_ => "4K+"
};
}
}
private static string ResolveBucket(int maxDim) => maxDim switch {
<= 480 => "SD (≤480p)",
<= 720 => "HD (≤720p)",
<= 1080 => "Full HD (≤1080p)",
<= 2160 => "4K (≤2160p)",
_ => "4K+"
};
}
+167 -129
View File
@@ -18,158 +18,161 @@
@* ---- Top-level counts ---- *@
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-7 g-3 mb-4">
<div class="col"><div class="card text-bg-primary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAssets.ToString("N0")</h5><small>Assets</small></div></div></div>
<div class="col"><div class="card text-bg-success"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalUsers.ToString("N0")</h5><small>Users</small></div></div></div>
<div class="col"><div class="card text-bg-info"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAlbums.ToString("N0")</h5><small>Albums</small></div></div></div>
<div class="col"><div class="card text-bg-warning"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalTags.ToString("N0")</h5><small>Tags</small></div></div></div>
<div class="col"><div class="card text-bg-secondary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalPeople.ToString("N0")</h5><small>People</small></div></div></div>
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFaces.ToString("N0")</h5><small>Faces</small></div></div></div>
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFolders.ToString("N0")</h5><small>Folders</small></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="primary" Value="@_stats.TotalAssets.ToString("N0")" Label="Assets"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="success" Value="@_stats.TotalUsers.ToString("N0")" Label="Users"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="info" Value="@_stats.TotalAlbums.ToString("N0")" Label="Albums"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="warning" Value="@_stats.TotalTags.ToString("N0")" Label="Tags"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="secondary" Value="@_stats.TotalPeople.ToString("N0")" Label="People"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFaces.ToString("N0")" Label="Faces"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFolders.ToString("N0")" Label="Folders"/></div>
</div>
@* ---- Recent activity ---- *@
<div class="row row-cols-1 row-cols-md-3 g-3 mb-4">
<div class="col"><div class="card border-primary"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-week"></i> Assets (last 7 days)</h6><h4 class="text-primary">@_stats.AssetsAddedLast7Days.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-month"></i> Assets (last 30 days)</h6><h4 class="text-success">@_stats.AssetsAddedLast30Days.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-info"><div class="card-body"><h6 class="card-title"><i class="bi bi-person-plus"></i> New Users (last 30 days)</h6><h4 class="text-info">@_stats.UsersRegisteredLast30Days.ToString("N0")</h4></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="primary" Icon="bi bi-calendar-week" Label="Assets (last 7 days)" Value="@_stats.AssetsAddedLast7Days.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-calendar-month" Label="Assets (last 30 days)" Value="@_stats.AssetsAddedLast30Days.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="info" Icon="bi bi-person-plus" Label="New Users (last 30 days)" Value="@_stats.UsersRegisteredLast30Days.ToString("N0")"/></div>
</div>
@* ---- Data completeness indicators ---- *@
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-4 g-3 mb-4">
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingMetadata.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingMetadata)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Metadata</small></div></div></div>
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingThumbnail.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingThumbnail)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Thumbnail</small><br/><small class="text-danger">@_stats.AssetsMissingThumbnailStale.ToString("N0") stale</small></div></div></div>
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPreviews.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPreviews)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Previews</small><br/><small class="text-danger">@_stats.AssetsMissingPreviewsStale.ToString("N0") stale</small></div></div></div>
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPhash.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPhash)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing pHash</small></div></div></div>
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoPerson.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Person</small></div></div></div>
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoAlbum.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Album</small></div></div></div>
<div class="col"><div class="card border-danger h-100"><div class="card-body text-center py-3"><h5>@_stats.AlbumsMissingCover.ToString("N0")</h5><small class="text-danger"><i class="bi bi-image"></i> Albums Missing Cover</small></div></div></div>
<div class="col"><div class="card border-secondary h-100"><div class="card-body text-center py-3"><h5>@_stats.CosplayersMissingProfile.ToString("N0")</h5><small class="text-muted"><i class="bi bi-person-badge"></i> Cosplayers Missing Profile</small></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingMetadata.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingMetadata, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Metadata"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingThumbnail.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingThumbnail, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Thumbnail" SubLabel="@($"{_stats.AssetsMissingThumbnailStale.ToString("N0")} stale")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPreviews.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPreviews, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Previews" SubLabel="@($"{_stats.AssetsMissingPreviewsStale.ToString("N0")} stale")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPhash.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPhash, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing pHash"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoPerson.ToString("N0")" Icon="bi bi-info-circle" Label="No Person"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoAlbum.ToString("N0")" Icon="bi bi-info-circle" Label="No Album"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="danger" EqualHeight="true" Value="@_stats.AlbumsMissingCover.ToString("N0")" Icon="bi bi-image" Label="Albums Missing Cover"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="secondary" EqualHeight="true" Value="@_stats.CosplayersMissingProfile.ToString("N0")" Icon="bi bi-person-badge" Label="Cosplayers Missing Profile"/></div>
</div>
@* ---- Duplicate / Orphan / Visibility ---- *@
<div class="row row-cols-1 row-cols-md-4 g-3 mb-4">
<div class="col"><div class="card border-secondary"><div class="card-body"><h6 class="card-title"><i class="bi bi-folder-x"></i> Orphan Assets</h6><h4 class="text-secondary">@_stats.OrphanAssets.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-globe2"></i> Public Assets</h6><h4 class="text-success">@_stats.PublicAssets.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-warning"><div class="card-body"><h6 class="card-title"><i class="bi bi-shield-lock"></i> Protected Assets</h6><h4 class="text-warning">@_stats.ProtectedAssets.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-danger"><div class="card-body"><h6 class="card-title"><i class="bi bi-lock"></i> Private Assets</h6><h4 class="text-danger">@_stats.PrivateAssets.ToString("N0")</h4></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="secondary" Icon="bi bi-folder-x" Label="Orphan Assets" Value="@_stats.OrphanAssets.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-globe2" Label="Public Assets" Value="@_stats.PublicAssets.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="warning" Icon="bi bi-shield-lock" Label="Protected Assets" Value="@_stats.ProtectedAssets.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="danger" Icon="bi bi-lock" Label="Private Assets" Value="@_stats.PrivateAssets.ToString("N0")"/></div>
</div>
<div class="row g-3">
@* ---- Assets by type + Storage side by side ---- *@
<div class="col-12 col-lg-6">
<div class="card mb-3">
<div class="card-header"><i class="bi bi-collection"></i> Assets by Type</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Type</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Type</th><th class="text-end">Size</th></tr></thead>
<tbody>
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@FormatBytes(kv.Value)</td></tr>
}
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@FormatBytes(_stats.TotalStorageBytes)</td></tr>
</tbody>
</table>
</div>
</div>
<StatTable Title="Assets by Type" Icon="bi bi-collection" MarginBottom="true">
<HeaderRow><th>Type</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
<StatTable Title="Storage by Type" Icon="bi bi-hdd-stack">
<HeaderRow><th>Type</th><th class="text-end">Size</th></HeaderRow>
<ChildContent>
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@Formatting.FormatBytes(kv.Value)</td></tr>
}
</ChildContent>
<FooterRow>
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@Formatting.FormatBytes(_stats.TotalStorageBytes)</td></tr>
</FooterRow>
</StatTable>
</div>
@* ---- Users by role + File format breakdown ---- *@
<div class="col-12 col-lg-6">
<div class="card mb-3">
<div class="card-header"><i class="bi bi-people"></i> Users by Role</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Role</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>MIME Type</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<StatTable Title="Users by Role" Icon="bi bi-people" MarginBottom="true">
<HeaderRow><th>Role</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
<StatTable Title="File Formats" Icon="bi bi-file-earmark-code">
<HeaderRow><th>MIME Type</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
</div>
@* ---- Resolution distribution ---- *@
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Bucket</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var bucket in _stats.ResolutionDistribution) {
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<StatTable Title="Resolution Distribution" Icon="bi bi-bounding-box-circles">
<HeaderRow><th>Bucket</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var bucket in _stats.ResolutionDistribution) {
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
</div>
@* ---- Top tags ---- *@
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header"><i class="bi bi-tags"></i> Top Tags</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Tag</th><th class="text-end">Assets</th></tr></thead>
<tbody>
@foreach (var tag in _stats.TopTags) {
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
}
@if (!_stats.TopTags.Any()) {
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
}
</tbody>
</table>
</div>
</div>
<StatTable Title="Top Tags" Icon="bi bi-tags">
<HeaderRow><th>Tag</th><th class="text-end">Assets</th></HeaderRow>
<ChildContent>
@foreach (var tag in _stats.TopTags) {
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
}
@if (!_stats.TopTags.Any()) {
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
}
</ChildContent>
</StatTable>
</div>
@* ---- Monthly growth ---- *@
<div class="col-12">
<div class="card">
<StatTable Title="Monthly Growth (last 12 months)" Icon="bi bi-graph-up-arrow">
<HeaderRow><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></HeaderRow>
<ChildContent>
@foreach (var m in _stats.MonthlyGrowth) {
<tr>
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
<td class="text-end">@m.NewAssets.ToString("N0")</td>
<td class="text-end">@m.NewUsers.ToString("N0")</td>
</tr>
}
</ChildContent>
</StatTable>
</div>
</div>
@* ---- Charts ---- *@
<div class="row g-3 mt-1">
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
<div class="card-body">
<StatChart Type="bar" ChartId="chart-storage" Labels="@StorageChartLabels" Datasets="@StorageChartDatasets" FormatYAsBytes="true"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
<div class="card-body">
<StatChart Type="doughnut" ChartId="chart-formats" Labels="@FormatChartLabels" Datasets="@FormatChartDatasets"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
<div class="card-body">
<StatChart Type="bar" ChartId="chart-resolution" Labels="@ResolutionChartLabels" Datasets="@ResolutionChartDatasets"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-graph-up-arrow"></i> Monthly Growth (last 12 months)</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></tr></thead>
<tbody>
@foreach (var m in _stats.MonthlyGrowth) {
<tr>
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
<td class="text-end">@m.NewAssets.ToString("N0")</td>
<td class="text-end">@m.NewUsers.ToString("N0")</td>
</tr>
}
</tbody>
</table>
<div class="card-body">
<StatChart Type="line" ChartId="chart-growth" Labels="@GrowthChartLabels" Datasets="@GrowthChartDatasets"/>
</div>
</div>
</div>
@@ -181,6 +184,51 @@
private StatsDto? _stats;
private bool _loading = true;
private static readonly string[] Palette = [
"#0d6efd", "#198754", "#0dcaf0", "#ffc107", "#fd7e14", "#dc3545", "#6f42c1", "#20c997", "#6610f2", "#d63384"
];
private List<string> StorageChartLabels =>
_stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => x.Key.ToString()).ToList();
private List<StatChart.ChartDataset> StorageChartDatasets => [
new() {
Label = "Storage",
Data = _stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => (double)x.Value).ToList(),
BackgroundColor = Palette.Take(_stats!.StorageByType.Count).ToList()
}
];
private List<string> FormatChartLabels =>
_stats!.FileFormatBreakdown.Take(8).Select(x => x.MimeType).ToList();
private List<StatChart.ChartDataset> FormatChartDatasets => [
new() {
Label = "Assets",
Data = _stats!.FileFormatBreakdown.Take(8).Select(x => (double)x.Count).ToList(),
BackgroundColor = Palette.Take(_stats!.FileFormatBreakdown.Take(8).Count()).ToList()
}
];
private List<string> ResolutionChartLabels =>
_stats!.ResolutionDistribution.Select(x => x.Label).ToList();
private List<StatChart.ChartDataset> ResolutionChartDatasets => [
new() {
Label = "Assets",
Data = _stats!.ResolutionDistribution.Select(x => (double)x.Count).ToList(),
BackgroundColor = Palette.Take(_stats!.ResolutionDistribution.Count).ToList()
}
];
private List<string> GrowthChartLabels =>
_stats!.MonthlyGrowth.Select(m => new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM")).ToList();
private List<StatChart.ChartDataset> GrowthChartDatasets => [
new() { Label = "New Assets", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewAssets).ToList(), BorderColor = "#0d6efd", Fill = false, Tension = 0.2 },
new() { Label = "New Users", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewUsers).ToList(), BorderColor = "#198754", Fill = false, Tension = 0.2 }
];
protected override async Task OnInitializedAsync() {
await LoadStats();
}
@@ -193,14 +241,4 @@
_loading = false;
StateHasChanged();
}
private string PercentOfTotal(int count) =>
_stats?.TotalAssets > 0 ? $"{(double)count / _stats.TotalAssets * 100:F1}%" : "0%";
private static string FormatBytes(long bytes) => bytes switch {
>= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F2} GB",
>= 1_048_576 => $"{bytes / 1_048_576.0:F2} MB",
>= 1_024 => $"{bytes / 1_024.0:F2} KB",
_ => $"{bytes} B"
};
}
}
@@ -0,0 +1,83 @@
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
<div style="height: @(Height)px;">
<canvas id="@ChartId"></canvas>
</div>
@code {
/// <summary>
/// A Chart.js dataset passed to the interop renderer.
/// </summary>
public class ChartDataset {
/// <summary>Dataset label shown in legends/tooltips.</summary>
public string Label { get; set; } = "";
/// <summary>Numeric values for the dataset.</summary>
public List<double> Data { get; set; } = [];
/// <summary>Per-point background colors (bar/doughnut).</summary>
public List<string>? BackgroundColor { get; set; }
/// <summary>Line color.</summary>
public string? BorderColor { get; set; }
/// <summary>Whether the area under a line chart is filled.</summary>
public bool Fill { get; set; }
/// <summary>Line smoothing (0 for straight segments).</summary>
public double Tension { get; set; }
}
/// <summary>Unique DOM id of the canvas. Defaults to a random value per instance.</summary>
[Parameter] public string ChartId { get; set; } = $"stats-chart-{Guid.NewGuid():N}";
/// <summary>The Chart.js chart type (e.g. "bar", "doughnut", "line").</summary>
[Parameter] public string Type { get; set; } = "bar";
/// <summary>Category labels for the chart.</summary>
[Parameter] public List<string>? Labels { get; set; }
/// <summary>Datasets to render.</summary>
[Parameter] public List<ChartDataset> Datasets { get; set; } = [];
/// <summary>Canvas container height in pixels.</summary>
[Parameter] public int Height { get; set; } = 280;
/// <summary>Whether the y-axis and tooltips should format values as bytes.</summary>
[Parameter] public bool FormatYAsBytes { get; set; }
private bool _rendered;
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
await RenderChart();
_rendered = true;
}
}
private async Task RenderChart() {
if (Labels is null || Datasets.Count == 0)
return;
await JSRuntime.InvokeVoidAsync("statsCharts.render", ChartId, new {
type = Type,
data = new {
labels = Labels,
datasets = Datasets
},
options = new {
responsive = true,
maintainAspectRatio = false
},
formatYAsBytes = FormatYAsBytes
});
}
/// <inheritdoc />
public async ValueTask DisposeAsync() {
if (_rendered) {
try {
await JSRuntime.InvokeVoidAsync("statsCharts.destroy", ChartId);
} catch (JSDisconnectedException) {
// Page navigated away; the canvas is already gone.
}
}
}
}
@@ -0,0 +1,74 @@
<div class="card @CardClasses">
<div class="card-body @(Centered ? "text-center py-3" : "")">
@if (Layout == StatCardLayout.Solid) {
<h5 class="card-title">@Value</h5>
<small>@Label</small>
} else if (Layout == StatCardLayout.Outline) {
<h5>@Value</h5>
@if (Percent is not null) {
<small class="text-muted">@Percent</small>
<br/>
}
<small class="text-@Variant">
@if (Icon is not null) {
<i class="bi @Icon"></i>
}
@Label
</small>
@if (SubLabel is not null) {
<br/>
<small class="text-@SubLabelVariant">@SubLabel</small>
}
} else {
<h6 class="card-title">
@if (Icon is not null) {
<i class="bi @Icon"></i>
}
@Label
</h6>
<h4 class="text-@Variant">@Value</h4>
}
</div>
</div>
@code {
/// <summary>Card layout variants.</summary>
public enum StatCardLayout { Solid, Outline, IconTitle }
/// <summary>The card layout variant.</summary>
[Parameter] public StatCardLayout Layout { get; set; } = StatCardLayout.Outline;
/// <summary>The displayed value (pre-formatted).</summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>The card label/title text.</summary>
[Parameter] public string Label { get; set; } = "";
/// <summary>The Bootstrap color variant (e.g. "primary", "warning").</summary>
[Parameter] public string Variant { get; set; } = "primary";
/// <summary>Optional Bootstrap Icons class (e.g. "bi bi-globe2").</summary>
[Parameter] public string? Icon { get; set; }
/// <summary>Optional percentage line shown on outline cards.</summary>
[Parameter] public string? Percent { get; set; }
/// <summary>Optional secondary line shown on outline cards (e.g. "N stale").</summary>
[Parameter] public string? SubLabel { get; set; }
/// <summary>Bootstrap color variant for the secondary line.</summary>
[Parameter] public string SubLabelVariant { get; set; } = "danger";
/// <summary>Whether to center the card body content vertically.</summary>
[Parameter] public bool Centered { get; set; } = true;
/// <summary>Whether the card should stretch to fill its column height.</summary>
[Parameter] public bool EqualHeight { get; set; }
private string CardClasses {
get {
var variant = Layout == StatCardLayout.Solid ? $"text-bg-{Variant}" : $"border-{Variant}";
return EqualHeight ? $"{variant} h-100" : variant;
}
}
}
@@ -0,0 +1,36 @@
<div class="card @(MarginBottom ? "mb-3" : "")">
<div class="card-header"><i class="bi @Icon"></i> @Title</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead>
<tr>@HeaderRow</tr>
</thead>
<tbody>
@ChildContent
@if (FooterRow is not null) {
@FooterRow
}
</tbody>
</table>
</div>
</div>
@code {
/// <summary>The card title shown in the header.</summary>
[Parameter] public string Title { get; set; } = "";
/// <summary>The Bootstrap Icons class shown in the header (e.g. "bi bi-collection").</summary>
[Parameter] public string Icon { get; set; } = "";
/// <summary>The table header cells.</summary>
[Parameter] public RenderFragment? HeaderRow { get; set; }
/// <summary>The table body rows.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Optional footer row (e.g. a bold total).</summary>
[Parameter] public RenderFragment? FooterRow { get; set; }
/// <summary>Whether to add a bottom margin below the card.</summary>
[Parameter] public bool MarginBottom { get; set; }
}
+27
View File
@@ -0,0 +1,27 @@
namespace MilkStream.Client.Services;
/// <summary>
/// Static formatting helpers used across the client.
/// </summary>
public static class Formatting {
/// <summary>
/// Formats a byte count into a human-readable size string (B/KB/MB/GB).
/// </summary>
/// <param name="bytes">The byte count to format.</param>
/// <returns>A human-readable size string.</returns>
public static string FormatBytes(long bytes) => bytes switch {
>= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F2} GB",
>= 1_048_576 => $"{bytes / 1_048_576.0:F2} MB",
>= 1_024 => $"{bytes / 1_024.0:F2} KB",
_ => $"{bytes} B"
};
/// <summary>
/// Formats a count as a percentage of a total.
/// </summary>
/// <param name="count">The partial count.</param>
/// <param name="total">The total to divide by.</param>
/// <returns>A percentage string with one decimal place, or "0%" when the total is not positive.</returns>
public static string PercentOfTotal(int count, int total) =>
total > 0 ? $"{count / (double)total * 100:F1}%" : "0%";
}
+2
View File
@@ -28,9 +28,11 @@
</div>
<script src="lib/js/bootstrap.bundle.min.js"></script>
<script src="lib/js/chart.umd.min.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
<script src="js/masonryObserver.js"></script>
<script src="js/profileCropper.js"></script>
<script src="js/statsCharts.js"></script>
</body>
</html>
@@ -0,0 +1,53 @@
window.statsCharts = {
charts: {},
render: function (id, config) {
this.destroy(id);
const ctx = document.getElementById(id);
if (!ctx) {
return;
}
const options = config.options || {};
if (config.formatYAsBytes) {
options.scales = options.scales || {};
options.scales.y = options.scales.y || {};
options.scales.y.ticks = options.scales.y.ticks || {};
options.scales.y.ticks.callback = function (value) {
return window.statsCharts.formatBytes(value);
};
options.plugins = options.plugins || {};
options.plugins.tooltip = options.plugins.tooltip || {};
options.plugins.tooltip.callbacks = options.plugins.tooltip.callbacks || {};
options.plugins.tooltip.callbacks.label = function (item) {
return window.statsCharts.formatBytes(item.parsed.y);
};
}
this.charts[id] = new Chart(ctx, config);
},
destroy: function (id) {
if (this.charts[id]) {
this.charts[id].destroy();
delete this.charts[id];
}
},
formatBytes: function (bytes) {
const gb = 1024 * 1024 * 1024;
const mb = 1024 * 1024;
const kb = 1024;
if (bytes >= gb) {
return (bytes / gb).toFixed(2) + ' GB';
}
if (bytes >= mb) {
return (bytes / mb).toFixed(2) + ' MB';
}
if (bytes >= kb) {
return (bytes / kb).toFixed(2) + ' KB';
}
return bytes + ' B';
}
};
File diff suppressed because one or more lines are too long