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
274 lines
16 KiB
Plaintext
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();
|
|
}
|
|
} |