mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-24 16:32:20 -04:00
Add top users by credentials to admin all time stats (#1136)
This commit is contained in:
committed by
Leendert de Borst
parent
58ae63c74b
commit
fb002e54b7
@@ -47,6 +47,11 @@ public class ServerStatistics
|
||||
/// </summary>
|
||||
public List<TopUserByEmails> TopUsersByEmails { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of top users by number of credentials.
|
||||
/// </summary>
|
||||
public List<TopUserByCredentials> TopUsersByCredentials { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of top IP addresses by user activity.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="TopUserByCredentials.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 number of credentials.
|
||||
/// </summary>
|
||||
public class TopUserByCredentials
|
||||
{
|
||||
/// <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 credentials.
|
||||
/// </summary>
|
||||
public int CredentialCount { get; set; }
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Top Users Analysis -->
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 xl: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">
|
||||
@@ -100,16 +100,22 @@
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_storageTableColumns">
|
||||
@{
|
||||
var storageStartIndex = (_storageCurrentPage - 1) * _pageSize + 1;
|
||||
var storageIndex = storageStartIndex;
|
||||
}
|
||||
@foreach (var user in _topUsersByStorage)
|
||||
{
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@storageIndex</td>
|
||||
<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>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right w-32">@user.StorageDisplaySize</td>
|
||||
</tr>
|
||||
storageIndex++;
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
@@ -123,6 +129,48 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Top Users by Credentials -->
|
||||
<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 Credentials</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most credentials stored in their vault</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (_topUsersByCredentials != null)
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_credentialTableColumns">
|
||||
@{
|
||||
var credentialStartIndex = (_credentialCurrentPage - 1) * _pageSize + 1;
|
||||
var credentialIndex = credentialStartIndex;
|
||||
}
|
||||
@foreach (var user in _topUsersByCredentials)
|
||||
{
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@credentialIndex</td>
|
||||
<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 w-24">@user.CredentialCount.ToString("N0")</td>
|
||||
</tr>
|
||||
credentialIndex++;
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
<Paginator CurrentPage="_credentialCurrentPage" PageSize="_pageSize" TotalRecords="_credentialTotalRecords" OnPageChanged="HandleCredentialPageChanged" />
|
||||
}
|
||||
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">
|
||||
@@ -135,16 +183,22 @@
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_aliasTableColumns">
|
||||
@{
|
||||
var aliasStartIndex = (_aliasCurrentPage - 1) * _pageSize + 1;
|
||||
var aliasIndex = aliasStartIndex;
|
||||
}
|
||||
@foreach (var user in _topUsersByAliases)
|
||||
{
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@aliasIndex</td>
|
||||
<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>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right w-24">@user.AliasCount.ToString("N0")</td>
|
||||
</tr>
|
||||
aliasIndex++;
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
@@ -163,23 +217,29 @@
|
||||
<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 Emails</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most emails stored in their aliases</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Users with the most emails stored</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (_topUsersByEmails != null)
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_emailTableColumns">
|
||||
@{
|
||||
var emailStartIndex = (_emailCurrentPage - 1) * _pageSize + 1;
|
||||
var emailIndex = emailStartIndex;
|
||||
}
|
||||
@foreach (var user in _topUsersByEmails)
|
||||
{
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-2 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-center w-12">@emailIndex</td>
|
||||
<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.EmailCount.ToString("N0")</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right w-24">@user.EmailCount.ToString("N0")</td>
|
||||
</tr>
|
||||
emailIndex++;
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
@@ -195,6 +255,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Top IP Addresses -->
|
||||
<div class="mt-6">
|
||||
@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">
|
||||
@@ -248,6 +309,7 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_loadingError)
|
||||
{
|
||||
@@ -278,6 +340,7 @@
|
||||
private List<TopUserByStorage>? _topUsersByStorage;
|
||||
private List<TopUserByAliases>? _topUsersByAliases;
|
||||
private List<TopUserByEmails>? _topUsersByEmails;
|
||||
private List<TopUserByCredentials>? _topUsersByCredentials;
|
||||
private List<TopIpAddress>? _topIpAddresses;
|
||||
private bool _ipAddressesLoading = true;
|
||||
private bool _loadingError = false;
|
||||
@@ -287,28 +350,40 @@
|
||||
private int _storageCurrentPage = 1;
|
||||
private int _aliasCurrentPage = 1;
|
||||
private int _emailCurrentPage = 1;
|
||||
private int _credentialCurrentPage = 1;
|
||||
private int _storageTotalRecords = 0;
|
||||
private int _aliasTotalRecords = 0;
|
||||
private int _emailTotalRecords = 0;
|
||||
private int _credentialTotalRecords = 0;
|
||||
|
||||
private readonly List<TableColumn> _storageTableColumns = new()
|
||||
{
|
||||
new() { Title = "#", PropertyName = "Index", Sortable = false },
|
||||
new() { Title = "User", PropertyName = "Username", Sortable = false },
|
||||
new() { Title = "Storage", PropertyName = "StorageDisplaySize", Sortable = false }
|
||||
};
|
||||
|
||||
private readonly List<TableColumn> _aliasTableColumns = new()
|
||||
{
|
||||
new() { Title = "#", PropertyName = "Index", Sortable = false },
|
||||
new() { Title = "User", PropertyName = "Username", Sortable = false },
|
||||
new() { Title = "Aliases", PropertyName = "AliasCount", Sortable = false }
|
||||
};
|
||||
|
||||
private readonly List<TableColumn> _emailTableColumns = new()
|
||||
{
|
||||
new() { Title = "#", PropertyName = "Index", Sortable = false },
|
||||
new() { Title = "User", PropertyName = "Username", Sortable = false },
|
||||
new() { Title = "Emails", PropertyName = "EmailCount", Sortable = false }
|
||||
};
|
||||
|
||||
private readonly List<TableColumn> _credentialTableColumns = new()
|
||||
{
|
||||
new() { Title = "#", PropertyName = "Index", Sortable = false },
|
||||
new() { Title = "User", PropertyName = "Username", Sortable = false },
|
||||
new() { Title = "Credentials", PropertyName = "CredentialCount", Sortable = false }
|
||||
};
|
||||
|
||||
private readonly List<TableColumn> _ipTableColumns = new()
|
||||
{
|
||||
new() { Title = "IP Range", PropertyName = "IpAddress", Sortable = false },
|
||||
@@ -337,6 +412,7 @@
|
||||
_topUsersByStorage = null;
|
||||
_topUsersByAliases = null;
|
||||
_topUsersByEmails = null;
|
||||
_topUsersByCredentials = null;
|
||||
_topIpAddresses = null;
|
||||
_ipAddressesLoading = true;
|
||||
_loadingError = false;
|
||||
@@ -345,9 +421,11 @@
|
||||
_storageCurrentPage = 1;
|
||||
_aliasCurrentPage = 1;
|
||||
_emailCurrentPage = 1;
|
||||
_credentialCurrentPage = 1;
|
||||
_storageTotalRecords = 0;
|
||||
_aliasTotalRecords = 0;
|
||||
_emailTotalRecords = 0;
|
||||
_credentialTotalRecords = 0;
|
||||
|
||||
StateHasChanged();
|
||||
|
||||
@@ -381,17 +459,19 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load paginated data for all three tables
|
||||
// Load paginated data for all four tables
|
||||
var storageTask = StatisticsService.GetTopUsersByStoragePaginatedAsync(_storageCurrentPage, _pageSize);
|
||||
var aliasTask = StatisticsService.GetTopUsersByAliasesPaginatedAsync(_aliasCurrentPage, _pageSize);
|
||||
var emailTask = StatisticsService.GetTopUsersByEmailsPaginatedAsync(_emailCurrentPage, _pageSize);
|
||||
var credentialTask = StatisticsService.GetTopUsersByCredentialsPaginatedAsync(_credentialCurrentPage, _pageSize);
|
||||
var ipTask = StatisticsService.GetServerStatisticsAsync();
|
||||
|
||||
await Task.WhenAll(storageTask, aliasTask, emailTask, ipTask);
|
||||
await Task.WhenAll(storageTask, aliasTask, emailTask, credentialTask, ipTask);
|
||||
|
||||
var (storageUsers, storageTotalCount) = await storageTask;
|
||||
var (aliasUsers, aliasTotalCount) = await aliasTask;
|
||||
var (emailUsers, emailTotalCount) = await emailTask;
|
||||
var (credentialUsers, credentialTotalCount) = await credentialTask;
|
||||
var stats = await ipTask;
|
||||
|
||||
_topUsersByStorage = storageUsers;
|
||||
@@ -403,6 +483,9 @@
|
||||
_topUsersByEmails = emailUsers;
|
||||
_emailTotalRecords = emailTotalCount;
|
||||
|
||||
_topUsersByCredentials = credentialUsers;
|
||||
_credentialTotalRecords = credentialTotalCount;
|
||||
|
||||
_topIpAddresses = stats.TopIpAddresses;
|
||||
_ipAddressesLoading = false;
|
||||
|
||||
@@ -435,6 +518,12 @@
|
||||
await LoadEmailPageData();
|
||||
}
|
||||
|
||||
private async Task HandleCredentialPageChanged(int newPage)
|
||||
{
|
||||
_credentialCurrentPage = newPage;
|
||||
await LoadCredentialPageData();
|
||||
}
|
||||
|
||||
private async Task LoadStoragePageData()
|
||||
{
|
||||
try
|
||||
@@ -491,4 +580,23 @@
|
||||
GlobalNotificationService.AddErrorMessage($"Error loading email statistics: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadCredentialPageData()
|
||||
{
|
||||
try
|
||||
{
|
||||
_topUsersByCredentials = null;
|
||||
StateHasChanged();
|
||||
|
||||
var (users, totalCount) = await StatisticsService.GetTopUsersByCredentialsPaginatedAsync(_credentialCurrentPage, _pageSize);
|
||||
_topUsersByCredentials = users;
|
||||
_credentialTotalRecords = totalCount;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GlobalNotificationService.AddErrorMessage($"Error loading credential statistics: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,9 +87,15 @@ public class StatisticsService
|
||||
stats.TotalEmailAttachments = results[3];
|
||||
|
||||
// Get top users data
|
||||
stats.TopUsersByStorage = await GetTopUsersByStorageAsync(10);
|
||||
stats.TopUsersByAliases = await GetTopUsersByAliasesAsync(10);
|
||||
stats.TopUsersByEmails = await GetTopUsersByEmailsAsync(10);
|
||||
var (storageUsers, _) = await GetTopUsersByStoragePaginatedAsync(1, 10);
|
||||
var (aliasUsers, _) = await GetTopUsersByAliasesPaginatedAsync(1, 10);
|
||||
var (emailUsers, _) = await GetTopUsersByEmailsPaginatedAsync(1, 10);
|
||||
var (credentialUsers, _) = await GetTopUsersByCredentialsPaginatedAsync(1, 10);
|
||||
|
||||
stats.TopUsersByStorage = storageUsers;
|
||||
stats.TopUsersByAliases = aliasUsers;
|
||||
stats.TopUsersByEmails = emailUsers;
|
||||
stats.TopUsersByCredentials = credentialUsers;
|
||||
stats.TopIpAddresses = await GetTopIpAddressesAsync();
|
||||
|
||||
return stats;
|
||||
@@ -236,6 +242,45 @@ public class StatisticsService
|
||||
return (users, totalCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets paginated top users by number of credentials.
|
||||
/// </summary>
|
||||
/// <param name="page">Page number (1-based).</param>
|
||||
/// <param name="pageSize">Number of items per page.</param>
|
||||
/// <returns>Paginated list of top users by credentials with total count.</returns>
|
||||
public async Task<(List<TopUserByCredentials> Users, int TotalCount)> GetTopUsersByCredentialsPaginatedAsync(int page, int pageSize)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// Get total count - using latest vault for each user
|
||||
var totalCount = await context.Vaults
|
||||
.GroupBy(v => v.UserId)
|
||||
.CountAsync();
|
||||
|
||||
// Get paginated data - using latest vault version for each user
|
||||
var topUsers = await context.Vaults
|
||||
.GroupBy(v => v.UserId)
|
||||
.Select(g => new
|
||||
{
|
||||
UserId = g.Key,
|
||||
Username = g.First().User.UserName,
|
||||
CredentialCount = g.OrderByDescending(v => v.Version).First().CredentialsCount,
|
||||
})
|
||||
.OrderByDescending(u => u.CredentialCount)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var users = topUsers.Select(u => new TopUserByCredentials
|
||||
{
|
||||
UserId = u.UserId,
|
||||
Username = u.Username ?? UnknownUsername,
|
||||
CredentialCount = u.CredentialCount,
|
||||
}).ToList();
|
||||
|
||||
return (users, totalCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user-specific usage statistics for both all-time and recent periods.
|
||||
/// </summary>
|
||||
@@ -387,94 +432,6 @@ public class StatisticsService
|
||||
return await context.EmailAttachments.CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top users by vault storage size.
|
||||
/// </summary>
|
||||
/// <param name="limit">Number of top users to retrieve.</param>
|
||||
/// <returns>List of top users by storage.</returns>
|
||||
private async Task<List<TopUserByStorage>> GetTopUsersByStorageAsync(int limit = 10)
|
||||
{
|
||||
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(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return topUsers.Select(u => new TopUserByStorage
|
||||
{
|
||||
UserId = u.UserId,
|
||||
Username = u.Username ?? UnknownUsername,
|
||||
StorageBytes = u.TotalStorageBytes,
|
||||
StorageDisplaySize = FormatKilobytes(u.TotalStorageBytes),
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top users by number of email aliases.
|
||||
/// </summary>
|
||||
/// <param name="limit">Number of top users to retrieve.</param>
|
||||
/// <returns>List of top users by aliases.</returns>
|
||||
private async Task<List<TopUserByAliases>> GetTopUsersByAliasesAsync(int limit = 10)
|
||||
{
|
||||
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(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return topUsers.Select(u => new TopUserByAliases
|
||||
{
|
||||
UserId = u.UserId!,
|
||||
Username = u.Username ?? UnknownUsername,
|
||||
AliasCount = u.AliasCount,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top users by number of emails stored.
|
||||
/// </summary>
|
||||
/// <param name="limit">Number of top users to retrieve.</param>
|
||||
/// <returns>List of top users by emails.</returns>
|
||||
private async Task<List<TopUserByEmails>> GetTopUsersByEmailsAsync(int limit = 10)
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
var topUsers = await context.Emails
|
||||
.GroupBy(e => e.EncryptionKey.UserId)
|
||||
.Select(g => new
|
||||
{
|
||||
UserId = g.Key,
|
||||
Username = g.First().EncryptionKey.User!.UserName,
|
||||
EmailCount = g.Count(),
|
||||
})
|
||||
.OrderByDescending(u => u.EmailCount)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return topUsers.Select(u => new TopUserByEmails
|
||||
{
|
||||
UserId = u.UserId!,
|
||||
Username = u.Username ?? UnknownUsername,
|
||||
EmailCount = u.EmailCount,
|
||||
}).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").
|
||||
|
||||
@@ -884,10 +884,6 @@ video {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.w-1\/3 {
|
||||
width: 33.333333%;
|
||||
}
|
||||
|
||||
.w-1\/4 {
|
||||
width: 25%;
|
||||
}
|
||||
@@ -904,10 +900,6 @@ video {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.w-2\/3 {
|
||||
width: 66.666667%;
|
||||
}
|
||||
|
||||
.w-20 {
|
||||
width: 5rem;
|
||||
}
|
||||
@@ -2690,6 +2682,10 @@ video {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xl\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.xl\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user