mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-12 09:23:42 -04:00
Add all-time stats page to admin (#967)
This commit is contained in:
committed by
Leendert de Borst
parent
e31f3df45b
commit
11d8c941d2
49
apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs
Normal file
49
apps/server/AliasVault.Admin/Main/Models/ServerStatistics.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="ServerStatistics.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model representing comprehensive server statistics and metrics.
|
||||
/// </summary>
|
||||
public class ServerStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of users registered on the server.
|
||||
/// </summary>
|
||||
public int TotalUsers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of email aliases created.
|
||||
/// </summary>
|
||||
public int TotalAliases { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of emails stored.
|
||||
/// </summary>
|
||||
public int TotalEmails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of email attachments.
|
||||
/// </summary>
|
||||
public int TotalEmailAttachments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of top users by storage size.
|
||||
/// </summary>
|
||||
public List<TopUserByStorage> TopUsersByStorage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of top users by number of aliases.
|
||||
/// </summary>
|
||||
public List<TopUserByAliases> TopUsersByAliases { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of top IP addresses by user activity.
|
||||
/// </summary>
|
||||
public List<TopIpAddress> TopIpAddresses { get; set; } = new();
|
||||
}
|
||||
34
apps/server/AliasVault.Admin/Main/Models/TopIpAddress.cs
Normal file
34
apps/server/AliasVault.Admin/Main/Models/TopIpAddress.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="TopIpAddress.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model representing an IP address with high user activity.
|
||||
/// </summary>
|
||||
public class TopIpAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the original (non-anonymized) IP address for filtering.
|
||||
/// </summary>
|
||||
public string OriginalIpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the anonymized IP address for display.
|
||||
/// </summary>
|
||||
public string IpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of unique users from this IP.
|
||||
/// </summary>
|
||||
public int UniqueUserCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last activity timestamp.
|
||||
/// </summary>
|
||||
public DateTime LastActivity { get; set; }
|
||||
}
|
||||
29
apps/server/AliasVault.Admin/Main/Models/TopUserByAliases.cs
Normal file
29
apps/server/AliasVault.Admin/Main/Models/TopUserByAliases.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="TopUserByAliases.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model representing a user with many email aliases.
|
||||
/// </summary>
|
||||
public class TopUserByAliases
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID.
|
||||
/// </summary>
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the username.
|
||||
/// </summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of aliases.
|
||||
/// </summary>
|
||||
public int AliasCount { get; set; }
|
||||
}
|
||||
34
apps/server/AliasVault.Admin/Main/Models/TopUserByStorage.cs
Normal file
34
apps/server/AliasVault.Admin/Main/Models/TopUserByStorage.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="TopUserByStorage.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model representing a user with high storage usage.
|
||||
/// </summary>
|
||||
public class TopUserByStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID.
|
||||
/// </summary>
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the username.
|
||||
/// </summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the storage size in bytes.
|
||||
/// </summary>
|
||||
public long StorageBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the human-readable storage size.
|
||||
/// </summary>
|
||||
public string StorageDisplaySize { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
@page "/all-time-stats"
|
||||
@using AliasVault.Admin.Main.Models
|
||||
@using AliasVault.Admin.Services
|
||||
@using AliasVault.Admin.Main.Pages.Dashboard.Components
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@inherits MainBase
|
||||
@inject StatisticsService StatisticsService
|
||||
|
||||
<LayoutPageTitle>All-Time Statistics</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="All-Time Statistics"
|
||||
Description="View metrics and insights into the usage totals of this AliasVault server.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
<div class="px-4 space-y-6">
|
||||
<!-- Overview Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Users</h3>
|
||||
@if (_totalUsers.HasValue)
|
||||
{
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalUsers.Value.ToString("N0")</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Email Aliases</h3>
|
||||
@if (_totalAliases.HasValue)
|
||||
{
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalAliases.Value.ToString("N0")</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Emails</h3>
|
||||
@if (_totalEmails.HasValue)
|
||||
{
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalEmails.Value.ToString("N0")</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Email attachments</h3>
|
||||
@if (_totalEmailAttachments.HasValue)
|
||||
{
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">@_totalEmailAttachments.Value.ToString("N0")</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="w-16 h-6 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Users Analysis -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Top Users by Storage -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top Users by Storage</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the largest vault storage requirements</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (_topUsersByStorage != null)
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_storageTableColumns">
|
||||
@foreach (var user in _topUsersByStorage)
|
||||
{
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="/users/@user.UserId" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
|
||||
@user.Username
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@user.StorageDisplaySize</td>
|
||||
</tr>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-6 py-8 flex justify-center">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Top Users by Aliases -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top Users by Aliases</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most email aliases created</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (_topUsersByAliases != null)
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_aliasTableColumns">
|
||||
@foreach (var user in _topUsersByAliases)
|
||||
{
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="/users/@user.UserId" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
|
||||
@user.Username
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@user.AliasCount.ToString("N0")</td>
|
||||
</tr>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-6 py-8 flex justify-center">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top IP Addresses -->
|
||||
@if (_topIpAddresses != null && _topIpAddresses.Any())
|
||||
{
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top IP Address Ranges</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">IP ranges with the most associated user accounts (last octet anonymized, successful logins only)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_ipTableColumns">
|
||||
@foreach (var ip in _topIpAddresses)
|
||||
{
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100 font-mono">
|
||||
<a href="/logging/auth?search=@Uri.EscapeDataString(ip.OriginalIpAddress)" class="text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
|
||||
@ip.IpAddress
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@ip.UniqueUserCount.ToString("N0")</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">@ip.LastActivity.ToString("MMM dd, yyyy HH:mm")</td>
|
||||
</tr>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_topIpAddresses != null && !_topIpAddresses.Any())
|
||||
{
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No IP Address Data</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">IP addresses are fully anonymized or no successful logins recorded.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_ipAddressesLoading)
|
||||
{
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top IP Address Ranges</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">IP ranges with the most associated user accounts (last octet anonymized, successful logins only)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-8 flex justify-center">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_loadingError)
|
||||
{
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Statistics</h3>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">Unable to retrieve some server statistics. Please try refreshing the page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// Basic statistics (loaded first for immediate feedback)
|
||||
private int? _totalUsers;
|
||||
private int? _totalAliases;
|
||||
private int? _totalEmails;
|
||||
private int? _totalEmailAttachments;
|
||||
|
||||
// Detailed statistics (loaded separately)
|
||||
private List<TopUserByStorage>? _topUsersByStorage;
|
||||
private List<TopUserByAliases>? _topUsersByAliases;
|
||||
private List<TopIpAddress>? _topIpAddresses;
|
||||
private bool _ipAddressesLoading = true;
|
||||
private bool _loadingError = false;
|
||||
|
||||
private readonly List<TableColumn> _storageTableColumns = new()
|
||||
{
|
||||
new() { Title = "User", PropertyName = "Username", Sortable = false },
|
||||
new() { Title = "Storage", PropertyName = "StorageDisplaySize", Sortable = false }
|
||||
};
|
||||
|
||||
private readonly List<TableColumn> _aliasTableColumns = new()
|
||||
{
|
||||
new() { Title = "User", PropertyName = "Username", Sortable = false },
|
||||
new() { Title = "Aliases", PropertyName = "AliasCount", Sortable = false }
|
||||
};
|
||||
|
||||
private readonly List<TableColumn> _ipTableColumns = new()
|
||||
{
|
||||
new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false },
|
||||
new() { Title = "Users", PropertyName = "UniqueUserCount", Sortable = false },
|
||||
new() { Title = "Last Activity", PropertyName = "LastActivity", Sortable = false }
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
// Load statistics asynchronously for better perceived performance
|
||||
_ = LoadBasicStatisticsAsync();
|
||||
_ = LoadDetailedStatisticsAsync();
|
||||
}
|
||||
|
||||
private void RefreshData()
|
||||
{
|
||||
// Reset all data
|
||||
_totalUsers = null;
|
||||
_totalAliases = null;
|
||||
_totalEmails = null;
|
||||
_totalEmailAttachments = null;
|
||||
_topUsersByStorage = null;
|
||||
_topUsersByAliases = null;
|
||||
_topIpAddresses = null;
|
||||
_ipAddressesLoading = true;
|
||||
_loadingError = false;
|
||||
|
||||
StateHasChanged();
|
||||
|
||||
// Reload statistics
|
||||
_ = LoadBasicStatisticsAsync();
|
||||
_ = LoadDetailedStatisticsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadBasicStatisticsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var stats = await StatisticsService.GetServerStatisticsAsync();
|
||||
|
||||
_totalUsers = stats.TotalUsers;
|
||||
_totalAliases = stats.TotalAliases;
|
||||
_totalEmails = stats.TotalEmails;
|
||||
_totalEmailAttachments = stats.TotalEmailAttachments;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage($"Error loading basic statistics: {ex.Message}");
|
||||
_loadingError = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDetailedStatisticsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var stats = await StatisticsService.GetServerStatisticsAsync();
|
||||
|
||||
_topUsersByStorage = stats.TopUsersByStorage;
|
||||
_topUsersByAliases = stats.TopUsersByAliases;
|
||||
_topIpAddresses = stats.TopIpAddresses;
|
||||
_ipAddressesLoading = false;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage($"Error loading detailed statistics: {ex.Message}");
|
||||
_loadingError = true;
|
||||
_ipAddressesLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@
|
||||
Title="AliasVault Admin"
|
||||
Description="Welcome to the AliasVault admin dashboard. Below you can find statistics about recent activity on this server.">
|
||||
<CustomActions>
|
||||
<a href="/all-time-stats" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-200 mr-3">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
|
||||
</svg>
|
||||
All-Time Stats
|
||||
</a>
|
||||
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="@(TotalRecords > 0 ? $"Auth logs ({TotalRecords:N0})" : "Auth logs")"
|
||||
Title="@GetTitle()"
|
||||
Description="This page shows an overview of recent auth attempts.">
|
||||
<CustomActions>
|
||||
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
|
||||
@@ -25,13 +25,13 @@
|
||||
|
||||
<div class="mb-3 flex space-x-4">
|
||||
<div class="flex w-full">
|
||||
<div class="w-2/3 pr-2">
|
||||
<div class="w-1/2 pr-2">
|
||||
<div class="relative">
|
||||
<SearchIcon />
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/3 pl-2">
|
||||
<div class="w-1/4 px-1">
|
||||
<select @bind="SelectedEventType" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
<option value="">All event types</option>
|
||||
@foreach (var eventType in Enum.GetValues<AuthEventType>())
|
||||
@@ -40,6 +40,12 @@
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-1/4 pl-2">
|
||||
<button type="button" @onclick="ToggleUniqueUsernames"
|
||||
class="w-full px-4 py-2 text-sm font-medium rounded border transition-colors duration-200 @(ShowUniqueUsernames ? "bg-orange-400 text-white border-orange-500 hover:bg-orange-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600")">
|
||||
@(ShowUniqueUsernames ? "✓ Unique users" : "Unique users")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,6 +134,14 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShowUniqueUsernames { get; set; } = false;
|
||||
|
||||
private void ToggleUniqueUsernames()
|
||||
{
|
||||
ShowUniqueUsernames = !ShowUniqueUsernames;
|
||||
_ = RefreshData();
|
||||
}
|
||||
|
||||
private string SortColumn { get; set; } = "Id";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
@@ -228,11 +242,33 @@ else
|
||||
|
||||
query = ApplySort(query);
|
||||
|
||||
TotalRecords = await query.CountAsync();
|
||||
LogList = await query
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
// Handle unique usernames filtering after getting all results (since GroupBy with OrderBy in Select is complex for EF)
|
||||
if (ShowUniqueUsernames)
|
||||
{
|
||||
// Get all matching records first
|
||||
var allLogs = await query.ToListAsync();
|
||||
|
||||
// Group by username and take the latest entry for each username
|
||||
var uniqueLogs = allLogs
|
||||
.GroupBy(x => x.Username)
|
||||
.Select(g => g.OrderByDescending(x => x.Timestamp).First())
|
||||
.ToList();
|
||||
|
||||
// Apply pagination to the unique results
|
||||
TotalRecords = uniqueLogs.Count;
|
||||
LogList = uniqueLogs
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
TotalRecords = await query.CountAsync();
|
||||
LogList = await query
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// Create user lookup dictionary for the current page
|
||||
var usernames = LogList.Select(x => x.Username).Distinct().Where(x => !string.IsNullOrEmpty(x)).ToList();
|
||||
@@ -296,6 +332,9 @@ else
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show confirmation modal to delete all logs.
|
||||
/// </summary>
|
||||
private async Task DeleteLogsWithConfirmation()
|
||||
{
|
||||
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))
|
||||
@@ -304,6 +343,9 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all logs.
|
||||
/// </summary>
|
||||
private async Task DeleteLogs()
|
||||
{
|
||||
IsLoading = true;
|
||||
@@ -317,4 +359,24 @@ else
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the title of the page.
|
||||
/// </summary>
|
||||
/// <returns>The title of the page.</returns>
|
||||
private string GetTitle()
|
||||
{
|
||||
if (TotalRecords == 0)
|
||||
{
|
||||
return "Auth logs";
|
||||
}
|
||||
|
||||
var title = $"Auth logs ({TotalRecords:N0})";
|
||||
if (ShowUniqueUsernames)
|
||||
{
|
||||
title += " - Unique usernames";
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AuthLoggingService>();
|
||||
builder.Services.AddScoped<ConfirmModalService>();
|
||||
builder.Services.AddScoped<ServerSettingsService>();
|
||||
builder.Services.AddTransient<StatisticsService>();
|
||||
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
|
||||
builder.Services.AddApexCharts();
|
||||
|
||||
|
||||
238
apps/server/AliasVault.Admin/Services/StatisticsService.cs
Normal file
238
apps/server/AliasVault.Admin/Services/StatisticsService.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="StatisticsService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Services;
|
||||
|
||||
using AliasServerDb;
|
||||
using AliasVault.Admin.Main.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Service for gathering comprehensive server statistics and metrics.
|
||||
/// </summary>
|
||||
public class StatisticsService
|
||||
{
|
||||
private readonly IAliasServerDbContextFactory _contextFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StatisticsService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contextFactory">Database context factory.</param>
|
||||
public StatisticsService(IAliasServerDbContextFactory contextFactory)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets comprehensive server statistics including counts, storage metrics, and top users.
|
||||
/// </summary>
|
||||
/// <returns>Server statistics object.</returns>
|
||||
public async Task<ServerStatistics> GetServerStatisticsAsync()
|
||||
{
|
||||
var stats = new ServerStatistics();
|
||||
|
||||
// Get basic counts in parallel
|
||||
var tasks = new[]
|
||||
{
|
||||
GetTotalUsersAsync(),
|
||||
GetTotalAliasesAsync(),
|
||||
GetTotalEmailsAsync(),
|
||||
GetTotalEmailAttachmentsAsync(),
|
||||
};
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
stats.TotalUsers = results[0];
|
||||
stats.TotalAliases = results[1];
|
||||
stats.TotalEmails = results[2];
|
||||
stats.TotalEmailAttachments = results[3];
|
||||
|
||||
// Get top users data
|
||||
stats.TopUsersByStorage = await GetTopUsersByStorageAsync();
|
||||
stats.TopUsersByAliases = await GetTopUsersByAliasesAsync();
|
||||
stats.TopIpAddresses = await GetTopIpAddressesAsync();
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats kilobytes into human-readable format.
|
||||
/// </summary>
|
||||
/// <param name="kilobytes">Number of kilobytes.</param>
|
||||
/// <returns>Formatted string (e.g., "1.5 MB").</returns>
|
||||
private static string FormatKilobytes(long kilobytes)
|
||||
{
|
||||
string[] suffixes = { "KB", "MB", "GB", "TB" };
|
||||
int counter = 0;
|
||||
decimal number = kilobytes;
|
||||
while (Math.Round(number / 1024) >= 1)
|
||||
{
|
||||
number /= 1024;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return $"{number:n1} {suffixes[counter]}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anonymizes the last octet of an IP address for privacy.
|
||||
/// </summary>
|
||||
/// <param name="ipAddress">The IP address to anonymize.</param>
|
||||
/// <returns>Anonymized IP address.</returns>
|
||||
private static string AnonymizeIpAddress(string ipAddress)
|
||||
{
|
||||
if (ipAddress == "x.x.x.x")
|
||||
{
|
||||
return ipAddress;
|
||||
}
|
||||
|
||||
var parts = ipAddress.Split('.');
|
||||
if (parts.Length == 4)
|
||||
{
|
||||
return $"{parts[0]}.{parts[1]}.{parts[2]}.xxx";
|
||||
}
|
||||
|
||||
// Handle IPv6 or other formats by masking the last segment
|
||||
var lastColonIndex = ipAddress.LastIndexOf(':');
|
||||
if (lastColonIndex > 0)
|
||||
{
|
||||
return ipAddress[..lastColonIndex] + ":xxx";
|
||||
}
|
||||
|
||||
return "xxx.xxx.xxx.xxx";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of users.
|
||||
/// </summary>
|
||||
/// <returns>Total user count.</returns>
|
||||
private async Task<int> GetTotalUsersAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
return await context.AliasVaultUsers.CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of email aliases created.
|
||||
/// </summary>
|
||||
/// <returns>Total alias count.</returns>
|
||||
private async Task<int> GetTotalAliasesAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
return await context.UserEmailClaims.CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of emails stored.
|
||||
/// </summary>
|
||||
/// <returns>Total email count.</returns>
|
||||
private async Task<int> GetTotalEmailsAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
return await context.Emails.CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of email attachments.
|
||||
/// </summary>
|
||||
/// <returns>Total email attachment count.</returns>
|
||||
private async Task<int> GetTotalEmailAttachmentsAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
return await context.EmailAttachments.CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top 10 users by vault storage size.
|
||||
/// </summary>
|
||||
/// <returns>List of top users by storage.</returns>
|
||||
private async Task<List<TopUserByStorage>> GetTopUsersByStorageAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// Get the latest vault for each user with their total storage
|
||||
var topUsers = await context.Vaults
|
||||
.GroupBy(v => v.UserId)
|
||||
.Select(g => new
|
||||
{
|
||||
UserId = g.Key,
|
||||
Username = g.First().User.UserName,
|
||||
TotalStorageBytes = g.OrderByDescending(v => v.Version).First().FileSize,
|
||||
})
|
||||
.OrderByDescending(u => u.TotalStorageBytes)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
return topUsers.Select(u => new TopUserByStorage
|
||||
{
|
||||
UserId = u.UserId,
|
||||
Username = u.Username ?? "Unknown",
|
||||
StorageBytes = u.TotalStorageBytes,
|
||||
StorageDisplaySize = FormatKilobytes(u.TotalStorageBytes),
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top 10 users by number of email aliases.
|
||||
/// </summary>
|
||||
/// <returns>List of top users by aliases.</returns>
|
||||
private async Task<List<TopUserByAliases>> GetTopUsersByAliasesAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
var topUsers = await context.UserEmailClaims
|
||||
.Where(uec => uec.UserId != null)
|
||||
.GroupBy(uec => uec.UserId)
|
||||
.Select(g => new
|
||||
{
|
||||
UserId = g.Key,
|
||||
Username = g.First().User!.UserName,
|
||||
AliasCount = g.Count(),
|
||||
})
|
||||
.OrderByDescending(u => u.AliasCount)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
return topUsers.Select(u => new TopUserByAliases
|
||||
{
|
||||
UserId = u.UserId!,
|
||||
Username = u.Username ?? "Unknown",
|
||||
AliasCount = u.AliasCount,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top 10 IP address ranges by number of associated user accounts.
|
||||
/// Only includes non-anonymized IPs (not "xxx.xxx.xxx.xxx").
|
||||
/// </summary>
|
||||
/// <returns>List of top IP addresses.</returns>
|
||||
private async Task<List<TopIpAddress>> GetTopIpAddressesAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// Get distinct IP addresses from successful auth logs only, excluding fully anonymized ones
|
||||
var ipStats = await context.AuthLogs
|
||||
.Where(al => al.IpAddress != null && al.IpAddress != "xxx.xxx.xxx.xxx" && al.IsSuccess)
|
||||
.GroupBy(al => al.IpAddress)
|
||||
.Select(g => new
|
||||
{
|
||||
IpAddress = g.Key,
|
||||
UniqueUsernames = g.Where(al => al.IsSuccess).Select(al => al.Username).Distinct().Count(),
|
||||
LastActivity = g.Max(al => al.Timestamp),
|
||||
})
|
||||
.OrderByDescending(ip => ip.UniqueUsernames)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
return ipStats.Select(ip => new TopIpAddress
|
||||
{
|
||||
OriginalIpAddress = ip.IpAddress!,
|
||||
IpAddress = AnonymizeIpAddress(ip.IpAddress!),
|
||||
UniqueUserCount = ip.UniqueUsernames,
|
||||
LastActivity = ip.LastActivity,
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -691,6 +691,10 @@ video {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -794,6 +798,10 @@ video {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.h-12 {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.h-20 {
|
||||
height: 5rem;
|
||||
}
|
||||
@@ -814,6 +822,10 @@ video {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-64 {
|
||||
height: 16rem;
|
||||
}
|
||||
|
||||
.h-7 {
|
||||
height: 1.75rem;
|
||||
}
|
||||
@@ -846,6 +858,10 @@ video {
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.w-12 {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.w-2\/3 {
|
||||
width: 66.666667%;
|
||||
}
|
||||
@@ -894,6 +910,18 @@ video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.w-1\/4 {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.min-w-full {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.max-w-2xl {
|
||||
max-width: 42rem;
|
||||
}
|
||||
@@ -928,6 +956,16 @@ video {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1004,6 +1042,10 @@ video {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
@@ -1145,10 +1187,19 @@ video {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.border-b-2 {
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
|
||||
.border-l-4 {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.border-blue-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(229 231 235 / var(--tw-border-opacity));
|
||||
@@ -1169,6 +1220,11 @@ video {
|
||||
border-color: rgb(244 149 65 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-red-200 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(254 202 202 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-red-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||
@@ -1179,6 +1235,26 @@ video {
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-blue-600 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(37 99 235 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-orange-600 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(234 88 12 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-orange-400 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(251 146 60 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.border-orange-500 {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(249 115 22 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
|
||||
@@ -1274,6 +1350,11 @@ video {
|
||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-orange-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(249 115 22 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-primary-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(253 222 133 / var(--tw-bg-opacity));
|
||||
@@ -1304,6 +1385,11 @@ video {
|
||||
background-color: rgb(184 112 47 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-purple-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(168 85 247 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 226 226 / var(--tw-bg-opacity));
|
||||
@@ -1349,6 +1435,26 @@ video {
|
||||
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-blue-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-orange-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 88 12 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-orange-400 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(251 146 60 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-orange-300 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(253 186 116 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-opacity-50 {
|
||||
--tw-bg-opacity: 0.5;
|
||||
}
|
||||
@@ -1452,6 +1558,11 @@ video {
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.py-6 {
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
@@ -1462,6 +1573,11 @@ video {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.px-1 {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
@@ -1506,11 +1622,24 @@ video {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
|
||||
.text-5xl {
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
@@ -1541,11 +1670,6 @@ video {
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2.25rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1566,6 +1690,10 @@ video {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -1578,6 +1706,10 @@ video {
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.tracking-wider {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
||||
@@ -1623,11 +1755,26 @@ video {
|
||||
color: rgb(17 24 39 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(22 163 74 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-green-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(22 101 52 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-indigo-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-orange-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(234 88 12 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-primary-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(214 131 56 / var(--tw-text-opacity));
|
||||
@@ -1638,6 +1785,16 @@ video {
|
||||
color: rgb(184 112 47 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-purple-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(147 51 234 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity));
|
||||
@@ -1668,6 +1825,11 @@ video {
|
||||
color: rgb(133 77 14 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -1738,6 +1900,10 @@ video {
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.duration-200 {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
.hover\:bg-gray-100:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||
@@ -1783,6 +1949,26 @@ video {
|
||||
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-200:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-blue-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-orange-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(194 65 12 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-orange-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(234 88 12 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-700:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
@@ -1808,6 +1994,16 @@ video {
|
||||
color: rgb(154 93 38 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-blue-800:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-blue-600:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -1879,6 +2075,15 @@ video {
|
||||
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-gray-500:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-offset-2:focus {
|
||||
--tw-ring-offset-width: 2px;
|
||||
}
|
||||
|
||||
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-divide-opacity: 1;
|
||||
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
|
||||
@@ -1928,6 +2133,11 @@ video {
|
||||
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:border-red-800:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(153 27 27 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-blue-800:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
|
||||
@@ -2103,6 +2313,31 @@ video {
|
||||
color: rgb(254 240 138 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-green-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(74 222 128 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-indigo-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(129 140 248 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-orange-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(251 146 60 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-purple-400:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(192 132 252 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-red-300:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(252 165 165 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
|
||||
--tw-placeholder-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-placeholder-opacity));
|
||||
@@ -2157,6 +2392,21 @@ video {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-gray-200:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(229 231 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-blue-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(147 197 253 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-blue-400:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:border-blue-500:focus:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
@@ -2385,6 +2635,14 @@ video {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.lg\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user