Compare commits
5
Commits
84d3f63a97
...
d3124c55bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3124c55bd | ||
|
|
c97b16ada9 | ||
|
|
5e7c053218 | ||
|
|
e1bb7d3330 | ||
|
|
0c8e653a5f |
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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+"
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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%";
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user