update top level client count stats to support filtering per game

This commit is contained in:
RaidMax 2023-04-19 19:55:33 -05:00
parent c53e0de7d0
commit 92992dfb13
8 changed files with 126 additions and 87 deletions

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Data.Abstractions;
using Data.Models;
using Data.Models.Client;
using Data.Models.Client.Stats;
using Data.Models.Server;
@ -40,11 +41,20 @@ namespace IW4MAdmin.Application.Misc
}
public async Task<(int?, DateTime?)>
MaxConcurrentClientsAsync(long? serverId = null, TimeSpan? overPeriod = null,
MaxConcurrentClientsAsync(long? serverId = null, Reference.Game? gameCode = null, TimeSpan? overPeriod = null,
CancellationToken token = default)
{
_snapshotCache.SetCacheItem(async (snapshots, cancellationToken) =>
_snapshotCache.SetCacheItem(async (snapshots, ids, cancellationToken) =>
{
Reference.Game? game = null;
long? id = null;
if (ids.Any())
{
game = (Reference.Game?)ids.First();
id = (long?)ids.Last();
}
var oldestEntry = overPeriod.HasValue
? DateTime.UtcNow - overPeriod.Value
: DateTime.UtcNow.AddDays(-1);
@ -52,9 +62,10 @@ namespace IW4MAdmin.Application.Misc
int? maxClients;
DateTime? maxClientsTime;
if (serverId != null)
if (id != null)
{
var clients = await snapshots.Where(snapshot => snapshot.ServerId == serverId)
var clients = await snapshots.Where(snapshot => snapshot.ServerId == id)
.Where(snapshot => game == null || snapshot.Server.GameName == game)
.Where(snapshot => snapshot.CapturedAt >= oldestEntry)
.OrderByDescending(snapshot => snapshot.ClientCount)
.Select(snapshot => new
@ -71,11 +82,12 @@ namespace IW4MAdmin.Application.Misc
else
{
var clients = await snapshots.Where(snapshot => snapshot.CapturedAt >= oldestEntry)
.Where(snapshot => game == null || snapshot.Server.GameName == game)
.GroupBy(snapshot => snapshot.PeriodBlock)
.Select(grp => new
{
ClientCount = grp.Sum(snapshot => (int?) snapshot.ClientCount),
Time = grp.Max(snapshot => (DateTime?) snapshot.CapturedAt)
ClientCount = grp.Sum(snapshot => (int?)snapshot.ClientCount),
Time = grp.Max(snapshot => (DateTime?)snapshot.CapturedAt)
})
.OrderByDescending(snapshot => snapshot.ClientCount)
.FirstOrDefaultAsync(cancellationToken);
@ -87,11 +99,12 @@ namespace IW4MAdmin.Application.Misc
_logger.LogDebug("Max concurrent clients since {Start} is {Clients}", oldestEntry, maxClients);
return (maxClients, maxClientsTime);
}, nameof(MaxConcurrentClientsAsync), _cacheTimeSpan, true);
}, nameof(MaxConcurrentClientsAsync), new object[] { gameCode, serverId }, _cacheTimeSpan, true);
try
{
return await _snapshotCache.GetCacheItem(nameof(MaxConcurrentClientsAsync), token);
return await _snapshotCache.GetCacheItem(nameof(MaxConcurrentClientsAsync),
new object[] { gameCode, serverId }, token);
}
catch (Exception ex)
{
@ -100,22 +113,30 @@ namespace IW4MAdmin.Application.Misc
}
}
public async Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, CancellationToken token = default)
public async Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, Reference.Game? gameCode = null, CancellationToken token = default)
{
_serverStatsCache.SetCacheItem(async (set, cancellationToken) =>
_serverStatsCache.SetCacheItem(async (set, ids, cancellationToken) =>
{
var count = await set.CountAsync(cancellationToken);
Reference.Game? game = null;
if (ids.Any())
{
game = (Reference.Game?)ids.First();
}
var count = await set.CountAsync(item => game == null || item.GameName == game,
cancellationToken);
var startOfPeriod =
DateTime.UtcNow.AddHours(-overPeriod?.TotalHours ?? -24);
var recentCount = await set.CountAsync(client => client.LastConnection >= startOfPeriod,
var recentCount = await set.CountAsync(client => (game == null || client.GameName == game) && client.LastConnection >= startOfPeriod,
cancellationToken);
return (count, recentCount);
}, nameof(_serverStatsCache), _cacheTimeSpan, true);
}, nameof(_serverStatsCache), new object[] { gameCode }, _cacheTimeSpan, true);
try
{
return await _serverStatsCache.GetCacheItem(nameof(_serverStatsCache), token);
return await _serverStatsCache.GetCacheItem(nameof(_serverStatsCache), new object[] { gameCode }, token);
}
catch (Exception ex)
{
@ -166,21 +187,28 @@ namespace IW4MAdmin.Application.Misc
public async Task<int> RankedClientsCountAsync(long? serverId = null, CancellationToken token = default)
{
_rankedClientsCache.SetCacheItem(async (set, cancellationToken) =>
_rankedClientsCache.SetCacheItem((set, ids, cancellationToken) =>
{
long? id = null;
if (ids.Any())
{
id = (long?)ids.First();
}
var fifteenDaysAgo = DateTime.UtcNow.AddDays(-15);
return await set
return set
.Where(rating => rating.Newest)
.Where(rating => rating.ServerId == serverId)
.Where(rating => rating.ServerId == id)
.Where(rating => rating.CreatedDateTime >= fifteenDaysAgo)
.Where(rating => rating.Client.Level != EFClient.Permission.Banned)
.Where(rating => rating.Ranking != null)
.CountAsync(cancellationToken);
}, nameof(_rankedClientsCache), serverId is null ? null: new[] { (object)serverId }, _cacheTimeSpan);
}, nameof(_rankedClientsCache), new object[] { serverId }, _cacheTimeSpan);
try
{
return await _rankedClientsCache.GetCacheItem(nameof(_rankedClientsCache), serverId, token);
return await _rankedClientsCache.GetCacheItem(nameof(_rankedClientsCache), new object[] { serverId }, token);
}
catch (Exception ex)
{

View File

@ -11,10 +11,11 @@ namespace Data.Abstractions
void SetCacheItem(Func<DbSet<TEntityType>, CancellationToken, Task<TReturnType>> itemGetter, string keyName,
TimeSpan? expirationTime = null, bool autoRefresh = false);
void SetCacheItem(Func<DbSet<TEntityType>, CancellationToken, Task<TReturnType>> itemGetter, string keyName,
void SetCacheItem(Func<DbSet<TEntityType>, IEnumerable<object>, CancellationToken, Task<TReturnType>> itemGetter, string keyName,
IEnumerable<object> ids = null, TimeSpan? expirationTime = null, bool autoRefresh = false);
Task<TReturnType> GetCacheItem(string keyName, CancellationToken token = default);
Task<TReturnType> GetCacheItem(string keyName, object id = null, CancellationToken token = default);
Task<TReturnType> GetCacheItem(string keyName, IEnumerable<object> ids = null, CancellationToken token = default);
}
}

View File

@ -18,7 +18,7 @@ namespace Data.Helpers
private readonly IDatabaseContextFactory _contextFactory;
private readonly ConcurrentDictionary<string, Dictionary<object, CacheState<TReturnType>>> _cacheStates = new();
private readonly object _defaultKey = new();
private readonly string _defaultKey = null;
private bool _autoRefresh;
private const int DefaultExpireMinutes = 15;
@ -29,7 +29,7 @@ namespace Data.Helpers
public string Key { get; set; }
public DateTime LastRetrieval { get; set; }
public TimeSpan ExpirationTime { get; set; }
public Func<DbSet<TEntityType>, CancellationToken, Task<TCacheType>> Getter { get; set; }
public Func<DbSet<TEntityType>, IEnumerable<object>, CancellationToken, Task<TCacheType>> Getter { get; set; }
public TCacheType Value { get; set; }
public bool IsSet { get; set; }
@ -53,10 +53,10 @@ namespace Data.Helpers
public void SetCacheItem(Func<DbSet<TEntityType>, CancellationToken, Task<TReturnType>> getter, string key,
TimeSpan? expirationTime = null, bool autoRefresh = false)
{
SetCacheItem(getter, key, null, expirationTime, autoRefresh);
SetCacheItem((set, _, token) => getter(set, token), key, null, expirationTime, autoRefresh);
}
public void SetCacheItem(Func<DbSet<TEntityType>, CancellationToken, Task<TReturnType>> getter, string key,
public void SetCacheItem(Func<DbSet<TEntityType>, IEnumerable<object>, CancellationToken, Task<TReturnType>> getter, string key,
IEnumerable<object> ids = null, TimeSpan? expirationTime = null, bool autoRefresh = false)
{
ids ??= new[] { _defaultKey };
@ -66,15 +66,14 @@ namespace Data.Helpers
_cacheStates.TryAdd(key, new Dictionary<object, CacheState<TReturnType>>());
}
foreach (var id in ids)
{
var cacheInstance = _cacheStates[key];
var id = GenerateKeyFromIds(ids);
lock (_cacheStates)
{
if (cacheInstance.ContainsKey(id))
{
continue;
return;
}
}
@ -98,15 +97,14 @@ namespace Data.Helpers
}
_timer = new Timer(state.ExpirationTime.TotalMilliseconds);
_timer.Elapsed += async (sender, args) => await RunCacheUpdate(state, CancellationToken.None);
_timer.Elapsed += async (sender, args) => await RunCacheUpdate(state, ids, CancellationToken.None);
_timer.Start();
}
}
public Task<TReturnType> GetCacheItem(string keyName, CancellationToken cancellationToken = default) =>
GetCacheItem(keyName, null, cancellationToken);
public async Task<TReturnType> GetCacheItem(string keyName, object id = null,
public async Task<TReturnType> GetCacheItem(string keyName, IEnumerable<object> ids = null,
CancellationToken cancellationToken = default)
{
if (!_cacheStates.ContainsKey(keyName))
@ -120,27 +118,27 @@ namespace Data.Helpers
lock (_cacheStates)
{
state = id is null ? cacheInstance.Values.First() : _cacheStates[keyName][id];
state = ids is null ? cacheInstance.Values.First() : _cacheStates[keyName][GenerateKeyFromIds(ids)];
}
// when auto refresh is off we want to check the expiration and value
// when auto refresh is on, we want to only check the value, because it'll be refreshed automatically
if ((state.IsExpired || !state.IsSet) && !_autoRefresh || _autoRefresh && !state.IsSet)
{
await RunCacheUpdate(state, cancellationToken);
await RunCacheUpdate(state, ids, cancellationToken);
}
return state.Value;
}
private async Task RunCacheUpdate(CacheState<TReturnType> state, CancellationToken token)
private async Task RunCacheUpdate(CacheState<TReturnType> state, IEnumerable<object> ids, CancellationToken token)
{
try
{
_logger.LogDebug("Running update for {ClassName} {@State}", GetType().Name, state);
await using var context = _contextFactory.CreateContext(false);
var set = context.Set<TEntityType>();
var value = await state.Getter(set, token);
var value = await state.Getter(set, ids, token);
state.Value = value;
state.IsSet = true;
state.LastRetrieval = DateTime.Now;
@ -150,5 +148,8 @@ namespace Data.Helpers
_logger.LogError(ex, "Could not get cached value for {Key}", state.Key);
}
}
private static string GenerateKeyFromIds(IEnumerable<object> ids) =>
string.Join("_", ids.Select(id => id?.ToString() ?? "null"));
}
}

View File

@ -1,5 +1,5 @@
using System;
using static SharedLibraryCore.Server;
using Data.Models;
namespace SharedLibraryCore.Dtos
{
@ -15,11 +15,11 @@ namespace SharedLibraryCore.Dtos
/// <summary>
/// specifies the game name filter
/// </summary>
public Game? Game { get; set; }
public Reference.Game? Game { get; set; }
/// <summary>
/// collection of unique game names being monitored
/// </summary>
public Game[] ActiveServerGames { get; set; }
public Reference.Game[] ActiveServerGames { get; set; }
}
}

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Data.Models;
using SharedLibraryCore.Dtos;
namespace SharedLibraryCore.Interfaces
@ -15,19 +16,21 @@ namespace SharedLibraryCore.Interfaces
/// Retrieves the max concurrent clients over a give time period for all servers or given server id
/// </summary>
/// <param name="serverId">ServerId to query on</param>
/// <param name="gameCode"><see cref="Reference.Game"/></param>
/// <param name="overPeriod">how far in the past to search</param>
/// <param name="token">CancellationToken</param>
/// <returns></returns>
Task<(int?, DateTime?)> MaxConcurrentClientsAsync(long? serverId = null, TimeSpan? overPeriod = null,
Task<(int?, DateTime?)> MaxConcurrentClientsAsync(long? serverId = null, Reference.Game? gameCode = null, TimeSpan? overPeriod = null,
CancellationToken token = default);
/// <summary>
/// Gets the total number of clients connected and total clients connected in the given time frame
/// </summary>
/// <param name="overPeriod">how far in the past to search</param>
/// <param name="gameCode"><see cref="Reference.Game"/></param>
/// <param name="token">CancellationToken</param>
/// <returns></returns>
Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, CancellationToken token = default);
Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, Reference.Game? gameCode = null, CancellationToken token = default);
/// <summary>
/// Retrieves the client count and history over the given period

View File

@ -2,6 +2,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Data.Models;
using Microsoft.AspNetCore.Mvc;
using SharedLibraryCore;
using SharedLibraryCore.Interfaces;
@ -21,13 +22,13 @@ public class Info : BaseController
}
[HttpGet]
public async Task<IActionResult> Get(int period = 24, CancellationToken token = default)
public async Task<IActionResult> Get(int period = 24, Reference.Game? game = null, CancellationToken token = default)
{
// todo: this is hardcoded currently because the cache doesn't take into consideration the duration, so
// we could impact the webfront usage too
var duration = TimeSpan.FromHours(24);
var (totalClients, totalRecentClients) =
await _serverDataViewer.ClientCountsAsync(duration, token);
await _serverDataViewer.ClientCountsAsync(duration, game, token);
var (maxConcurrent, maxConcurrentTime) = await _serverDataViewer.MaxConcurrentClientsAsync(overPeriod: duration, token: token);
var response = new InfoResponse
{

View File

@ -7,8 +7,8 @@ using SharedLibraryCore.Interfaces;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Data.Models;
using Microsoft.Extensions.Logging;
using static SharedLibraryCore.Server;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace WebfrontCore.Controllers
@ -27,26 +27,31 @@ namespace WebfrontCore.Controllers
_serverDataViewer = serverDataViewer;
}
public async Task<IActionResult> Index(Game? game = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> Index(Reference.Game? game = null,
CancellationToken cancellationToken = default)
{
ViewBag.Description = Localization["WEBFRONT_DESCRIPTION_HOME"];
ViewBag.Title = Localization["WEBFRONT_HOME_TITLE"];
ViewBag.Keywords = Localization["WEBFRONT_KEWORDS_HOME"];
var servers = Manager.GetServers().Where(_server => !game.HasValue || _server.GameName == game);
var (clientCount, time) = await _serverDataViewer.MaxConcurrentClientsAsync(token: cancellationToken);
var (count, recentCount) = await _serverDataViewer.ClientCountsAsync(token: cancellationToken);
var servers = Manager.GetServers().Where(server => game is null || server.GameName == (Server.Game?)game)
.ToList();
var (clientCount, time) =
await _serverDataViewer.MaxConcurrentClientsAsync(gameCode: game, token: cancellationToken);
var (count, recentCount) =
await _serverDataViewer.ClientCountsAsync(gameCode: game, token: cancellationToken);
var model = new IW4MAdminInfo()
var model = new IW4MAdminInfo
{
TotalAvailableClientSlots = servers.Sum(_server => _server.MaxClients),
TotalOccupiedClientSlots = servers.SelectMany(_server => _server.GetClientsAsList()).Count(),
TotalAvailableClientSlots = servers.Sum(server => server.MaxClients),
TotalOccupiedClientSlots = servers.SelectMany(server => server.GetClientsAsList()).Count(),
TotalClientCount = count,
RecentClientCount = recentCount,
MaxConcurrentClients = clientCount ?? 0,
MaxConcurrentClientsTime = time ?? DateTime.UtcNow,
Game = game,
ActiveServerGames = Manager.GetServers().Select(_server => _server.GameName).Distinct().ToArray()
ActiveServerGames = Manager.GetServers().Select(server => (Reference.Game)server.GameName).Distinct()
.ToArray()
};
return View(model);

View File

@ -10,7 +10,6 @@ using Data.Models.Client.Stats;
using IW4MAdmin.Plugins.Stats.Helpers;
using SharedLibraryCore.Configuration;
using SharedLibraryCore.Interfaces;
using static SharedLibraryCore.Server;
namespace WebfrontCore.ViewComponents
{
@ -28,19 +27,20 @@ namespace WebfrontCore.ViewComponents
_defaultSettings = defaultSettings;
}
public IViewComponentResult Invoke(Game? game)
public IViewComponentResult Invoke(Reference.Game? game)
{
if (game.HasValue)
{
ViewBag.Maps = _defaultSettings.Maps.FirstOrDefault(map => map.Game == game)?.Maps.ToList() ??
new List<Map>();
ViewBag.Maps = _defaultSettings.Maps?.FirstOrDefault(map => map.Game == (Server.Game)game)?.Maps
?.ToList() ?? new List<Map>();
}
else
{
ViewBag.Maps = _defaultSettings.Maps.SelectMany(maps => maps.Maps).ToList();
ViewBag.Maps = _defaultSettings.Maps?.SelectMany(maps => maps.Maps).ToList();
}
var servers = Program.Manager.GetServers().Where(server => !game.HasValue || server.GameName == game);
var servers = Program.Manager.GetServers()
.Where(server => game is null || server.GameName == (Server.Game)game);
var serverInfo = new List<ServerInfo>();