Files
MilkyShots/MilkStream.Client/Components/Pages/Stats.razor
T
REDCODE 2ded40d2a0 feat(stats): add manual refresh button with last-updated timestamp
Adds a Refresh button to the stats page header with an in-progress
spinner, plus a 'last updated' local-time timestamp. Clicking it refetches
the stats payload (bypassing nothing server-side; the API cache still
applies its 60s TTL).

Refs #177
2026-08-17 23:44:02 +02:00

274 lines
16 KiB
Plaintext

@page "/Stats"
@using Butter.Dtos.Stats
@inject LoginService LoginService
@inject StatsService StatsService
<PageTitle>Statistics</PageTitle>
@if (_loading) {
<div class="d-flex justify-content-center align-items-center" style="min-height: 300px;">
<LoadSpinner />
</div>
} else if (_stats == null) {
<div class="alert alert-danger mt-3">Failed to load statistics. You may not have the required permissions.</div>
} else {
<div class="container-fluid p-3">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="mb-0"><i class="bi bi-bar-chart-fill"></i> Statistics</h3>
<div class="d-flex align-items-center gap-2">
@if (_lastUpdated is not null) {
<small class="text-muted">Updated @_lastUpdated.Value.ToLocalTime().ToString("HH:mm:ss")</small>
}
<button class="btn btn-sm btn-outline-primary" disabled="@_refreshing" @onclick="Refresh">
@if (_refreshing) {
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Refreshing…</span>
} else {
<i class="bi bi-arrow-clockwise me-1"></i>
<span>Refresh</span>
}
</button>
</div>
</div>
@* ---- Top-level counts ---- *@
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-7 g-3 mb-4">
<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"><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"><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"><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">
<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">
<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">
<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">
<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">
<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">
<StatChart Type="line" ChartId="chart-growth" Labels="@GrowthChartLabels" Datasets="@GrowthChartDatasets"/>
</div>
</div>
</div>
</div>
</div>
}
@code {
private StatsDto? _stats;
private bool _loading = true;
private bool _refreshing;
private DateTime? _lastUpdated;
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();
}
private async Task Refresh() {
if (_refreshing)
return;
_refreshing = true;
StateHasChanged();
await LoadStats();
_refreshing = false;
StateHasChanged();
}
private async Task LoadStats() {
_loading = true;
StateHasChanged();
_stats = await StatsService.GetStatsAsync();
_lastUpdated = DateTime.UtcNow;
_loading = false;
StateHasChanged();
}
}