persist client count history data across reboots and allow for configurable timespan

This commit is contained in:
RaidMax 2021-08-29 13:10:10 -05:00
parent 02e5e78f67
commit deff4f2947
22 changed files with 213 additions and 59 deletions

View File

@ -1002,10 +1002,13 @@ namespace IW4MAdmin
LastMessage = DateTime.Now - start;
lastCount = DateTime.Now;
var appConfig = _serviceProvider.GetService<ApplicationConfiguration>();
// update the player history
if ((lastCount - playerCountStart).TotalMinutes >= PlayerHistory.UpdateInterval)
if (lastCount - playerCountStart >= appConfig.ServerDataCollectionInterval)
{
while (ClientHistory.Count > ((60 / PlayerHistory.UpdateInterval) * 12)) // 12 times a hour for 12 hours
var maxItems = Math.Ceiling(appConfig.MaxClientHistoryTime.TotalMinutes /
appConfig.ServerDataCollectionInterval.TotalMinutes);
while ( ClientHistory.Count > maxItems)
{
ClientHistory.Dequeue();
}

View File

@ -4,8 +4,6 @@ using System.Threading;
using System.Threading.Tasks;
using Data.Abstractions;
using Data.Models.Client.Stats;
using SharedLibraryCore.Database;
using SharedLibraryCore.Interfaces;
namespace IW4MAdmin.Application.Migration
{

View File

@ -10,6 +10,7 @@ using Data.Models.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
using SharedLibraryCore.Configuration;
using ILogger = Microsoft.Extensions.Logging.ILogger;
using SharedLibraryCore.Interfaces;
@ -21,14 +22,16 @@ namespace IW4MAdmin.Application.Misc
private readonly ILogger _logger;
private readonly IManager _manager;
private readonly IDatabaseContextFactory _contextFactory;
private readonly ApplicationConfiguration _appConfig;
private bool _inProgress;
private TimeSpan _period;
public ServerDataCollector(ILogger<ServerDataCollector> logger, IManager manager,
IDatabaseContextFactory contextFactory)
public ServerDataCollector(ILogger<ServerDataCollector> logger, ApplicationConfiguration appConfig,
IManager manager, IDatabaseContextFactory contextFactory)
{
_logger = logger;
_appConfig = appConfig;
_manager = manager;
_contextFactory = contextFactory;
}
@ -42,7 +45,9 @@ namespace IW4MAdmin.Application.Misc
_logger.LogDebug("Initializing data collection with {Name}", nameof(ServerDataCollector));
_inProgress = true;
_period = period ?? TimeSpan.FromMinutes(Utilities.IsDevelopment ? 1 : 5);
_period = period ?? (Utilities.IsDevelopment
? TimeSpan.FromMinutes(1)
: _appConfig.ServerDataCollectionInterval);
while (!cancellationToken.IsCancellationRequested)
{

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -8,6 +9,8 @@ using Data.Models.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
using SharedLibraryCore.Dtos;
using SharedLibraryCore.Helpers;
using SharedLibraryCore.Interfaces;
using ILogger = Microsoft.Extensions.Logging.ILogger;
@ -19,16 +22,19 @@ namespace IW4MAdmin.Application.Misc
private readonly ILogger _logger;
private readonly IDataValueCache<EFServerSnapshot, int> _snapshotCache;
private readonly IDataValueCache<EFClient, (int, int)> _serverStatsCache;
private readonly IDataValueCache<EFServerSnapshot, List<ClientHistoryInfo>> _clientHistoryCache;
private readonly TimeSpan? _cacheTimeSpan =
Utilities.IsDevelopment ? TimeSpan.FromSeconds(1) : (TimeSpan?) TimeSpan.FromMinutes(1);
public ServerDataViewer(ILogger<ServerDataViewer> logger, IDataValueCache<EFServerSnapshot, int> snapshotCache,
IDataValueCache<EFClient, (int, int)> serverStatsCache)
IDataValueCache<EFClient, (int, int)> serverStatsCache,
IDataValueCache<EFServerSnapshot, List<ClientHistoryInfo>> clientHistoryCache)
{
_logger = logger;
_snapshotCache = snapshotCache;
_serverStatsCache = serverStatsCache;
_clientHistoryCache = clientHistoryCache;
}
public async Task<int> MaxConcurrentClientsAsync(long? serverId = null, TimeSpan? overPeriod = null,
@ -45,14 +51,14 @@ namespace IW4MAdmin.Application.Misc
{
maxClients = await snapshots.Where(snapshot => snapshot.ServerId == serverId)
.Where(snapshot => snapshot.CapturedAt >= oldestEntry)
.MaxAsync(snapshot => (int?)snapshot.ClientCount, cancellationToken) ?? 0;
.MaxAsync(snapshot => (int?) snapshot.ClientCount, cancellationToken) ?? 0;
}
else
{
maxClients = await snapshots.Where(snapshot => snapshot.CapturedAt >= oldestEntry)
.GroupBy(snapshot => snapshot.PeriodBlock)
.Select(grp => grp.Sum(snapshot => (int?)snapshot.ClientCount))
.Select(grp => grp.Sum(snapshot => (int?) snapshot.ClientCount))
.MaxAsync(cancellationToken) ?? 0;
}
@ -95,5 +101,43 @@ namespace IW4MAdmin.Application.Misc
return (0, 0);
}
}
public async Task<IEnumerable<ClientHistoryInfo>> ClientHistoryAsync(TimeSpan? overPeriod = null, CancellationToken token = default)
{
_clientHistoryCache.SetCacheItem(async (set, cancellationToken) =>
{
var oldestEntry = overPeriod.HasValue
? DateTime.UtcNow - overPeriod.Value
: DateTime.UtcNow.AddHours(-12);
var history = await set.Where(snapshot => snapshot.CapturedAt >= oldestEntry)
.Select(snapshot =>
new
{
snapshot.ServerId,
snapshot.CapturedAt,
snapshot.ClientCount
})
.OrderBy(snapshot => snapshot.CapturedAt)
.ToListAsync(cancellationToken);
return history.GroupBy(snapshot => snapshot.ServerId).Select(byServer => new ClientHistoryInfo
{
ServerId = byServer.Key,
ClientCounts = byServer.Select(snapshot => new ClientCountSnapshot()
{Time = snapshot.CapturedAt, ClientCount = snapshot.ClientCount}).ToList()
}).ToList();
}, nameof(_clientHistoryCache), TimeSpan.MaxValue);
try
{
return await _clientHistoryCache.GetCacheItem(nameof(_clientHistoryCache), token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not retrieve data for {Name}", nameof(ClientHistoryAsync));
return Enumerable.Empty<ClientHistoryInfo>();
}
}
}
}

View File

@ -8,7 +8,7 @@
<PackageId>RaidMax.IW4MAdmin.Data</PackageId>
<Title>RaidMax.IW4MAdmin.Data</Title>
<Authors />
<PackageVersion>1.0.5</PackageVersion>
<PackageVersion>1.0.6</PackageVersion>
</PropertyGroup>
<ItemGroup>

View File

@ -22,33 +22,36 @@ namespace Data.Helpers
public TimeSpan ExpirationTime { get; set; }
public Func<DbSet<T>, CancellationToken, Task<V>> Getter { get; set; }
public V Value { get; set; }
public bool IsExpired => (DateTime.Now - LastRetrieval.Add(ExpirationTime)).TotalSeconds > 0;
public bool IsExpired => ExpirationTime != TimeSpan.MaxValue &&
(DateTime.Now - LastRetrieval.Add(ExpirationTime)).TotalSeconds > 0;
}
public DataValueCache(ILogger<DataValueCache<T, V>> logger, IDatabaseContextFactory contextFactory)
{
_logger = logger;
_contextFactory = contextFactory;
}
public void SetCacheItem(Func<DbSet<T>, CancellationToken, Task<V>> getter, string key, TimeSpan? expirationTime = null)
public void SetCacheItem(Func<DbSet<T>, CancellationToken, Task<V>> getter, string key,
TimeSpan? expirationTime = null)
{
if (_cacheStates.ContainsKey(key))
{
_logger.LogDebug("Cache key {key} is already added", key);
return;
}
var state = new CacheState()
{
Key = key,
Getter = getter,
ExpirationTime = expirationTime ?? TimeSpan.FromMinutes(DefaultExpireMinutes)
};
_cacheStates.Add(key, state);
}
public async Task<V> GetCacheItem(string keyName, CancellationToken cancellationToken = default)
{
if (!_cacheStates.ContainsKey(keyName))
@ -58,7 +61,7 @@ namespace Data.Helpers
var state = _cacheStates[keyName];
if (state.IsExpired)
if (state.IsExpired || state.Value == null)
{
await RunCacheUpdate(state, cancellationToken);
}

View File

@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.SyndicationFeed.ReaderWriter" Version="1.0.2" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.27.1" PrivateAssets="All" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.29.1" PrivateAssets="All" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">

View File

@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.27.1" PrivateAssets="All" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.29.1" PrivateAssets="All" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">

View File

@ -23,7 +23,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.27.1" PrivateAssets="All" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.29.1" PrivateAssets="All" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">

View File

@ -19,7 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.27.1" PrivateAssets="All" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.29.1" PrivateAssets="All" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">

View File

@ -16,7 +16,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.27.1" PrivateAssets="All" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.29.1" PrivateAssets="All" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">

View File

@ -17,7 +17,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.27.1" PrivateAssets="All" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.29.1" PrivateAssets="All" />
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">

View File

@ -20,7 +20,7 @@
</Target>
<ItemGroup>
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.27.1" PrivateAssets="All" />
<PackageReference Include="RaidMax.IW4MAdmin.SharedLibraryCore" Version="2021.8.29.1" PrivateAssets="All" />
</ItemGroup>
</Project>

View File

@ -141,7 +141,15 @@ namespace SharedLibraryCore.Configuration
[LocalizedDisplayName(("WEBFRONT_CONFIGURATION_ENABLE_PRIVILEGED_USER_PRIVACY"))]
public bool EnablePrivilegedUserPrivacy { get; set; }
[ConfigurationIgnore]
public bool EnableImplicitAccountLinking { get; set; } = false;
[ConfigurationIgnore]
public TimeSpan MaxClientHistoryTime { get; set; } = TimeSpan.FromHours(12);
[ConfigurationIgnore]
public TimeSpan ServerDataCollectionInterval { get; set; } = TimeSpan.FromMinutes(5);
public Dictionary<Permission, string> OverridePermissionLevelNames { get; set; } = Enum
.GetValues(typeof(Permission))
.Cast<Permission>()

View File

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace SharedLibraryCore.Dtos
{
public class ClientHistoryInfo
{
public long ServerId { get; set; }
public List<ClientCountSnapshot> ClientCounts { get; set; }
}
public class ClientCountSnapshot
{
public DateTime Time { get; set; }
public string TimeString => Time.ToString("yyyy-MM-ddTHH:mm:ssZ");
public int ClientCount { get; set; }
}
}

View File

@ -17,6 +17,7 @@ namespace SharedLibraryCore.Dtos
public List<ChatInfo> ChatHistory { get; set; }
public List<PlayerInfo> Players { get; set; }
public Helpers.PlayerHistory[] PlayerHistory { get; set; }
public List<ClientCountSnapshot> ClientCountHistory { get; set; }
public long ID { get; set; }
public bool Online { get; set; }
public string ConnectProtocolUrl { get; set; }

View File

@ -1,4 +1,5 @@
using System;
using SharedLibraryCore.Dtos;
namespace SharedLibraryCore.Helpers
{
@ -31,5 +32,14 @@ namespace SharedLibraryCore.Helpers
/// Used by CanvasJS as a point on the y axis
/// </summary>
public int y { get; }
public ClientCountSnapshot ToClientCountSnapshot()
{
return new ClientCountSnapshot
{
ClientCount = y,
Time = When
};
}
}
}

View File

@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using SharedLibraryCore.Dtos;
using SharedLibraryCore.Helpers;
namespace SharedLibraryCore.Interfaces
{
@ -22,8 +25,16 @@ namespace SharedLibraryCore.Interfaces
/// 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="token"></param>
/// <param name="token">CancellationToken</param>
/// <returns></returns>
Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, CancellationToken token = default);
/// <summary>
/// Retrieves the client count and history over the given period
/// </summary>
/// <param name="overPeriod">how far in the past to search</param>
/// <param name="token">CancellationToken</param>
/// <returns></returns>
Task<IEnumerable<ClientHistoryInfo>> ClientHistoryAsync(TimeSpan? overPeriod = null, CancellationToken token = default);
}
}

View File

@ -4,7 +4,7 @@
<OutputType>Library</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<PackageId>RaidMax.IW4MAdmin.SharedLibraryCore</PackageId>
<Version>2021.8.27.1</Version>
<Version>2021.8.29.1</Version>
<Authors>RaidMax</Authors>
<Company>Forever None</Company>
<Configurations>Debug;Release;Prerelease</Configurations>
@ -19,7 +19,7 @@
<IsPackable>true</IsPackable>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Description>Shared Library for IW4MAdmin</Description>
<PackageVersion>2021.8.27.1</PackageVersion>
<PackageVersion>2021.8.29.1</PackageVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Prerelease|AnyCPU'">
@ -44,7 +44,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="3.1.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="3.1.10" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="RaidMax.IW4MAdmin.Data" Version="1.0.5" />
<PackageReference Include="RaidMax.IW4MAdmin.Data" Version="1.0.6" />
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
<PackageReference Include="SimpleCrypto.NetCore" Version="1.0.0" />
</ItemGroup>

View File

@ -1,45 +1,94 @@
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using SharedLibraryCore;
using SharedLibraryCore.Dtos;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Data.Models.Client.Stats;
using Microsoft.AspNetCore.Hosting.Server;
using SharedLibraryCore.Configuration;
using SharedLibraryCore.Interfaces;
using static SharedLibraryCore.Server;
namespace WebfrontCore.ViewComponents
{
public class ServerListViewComponent : ViewComponent
{
private readonly IServerDataViewer _serverDataViewer;
private readonly ApplicationConfiguration _appConfig;
public ServerListViewComponent(IServerDataViewer serverDataViewer,
ApplicationConfiguration applicationConfiguration)
{
_serverDataViewer = serverDataViewer;
_appConfig = applicationConfiguration;
}
public IViewComponentResult Invoke(Game? game)
{
var servers = Program.Manager.GetServers().Where(_server => !game.HasValue || _server.GameName == game);
var servers = Program.Manager.GetServers().Where(server => !game.HasValue || server.GameName == game);
var serverInfo = servers.Select(s => new ServerInfo()
var serverInfo = new List<ServerInfo>();
foreach (var server in servers)
{
Name = s.Hostname,
ID = s.EndPoint,
Port = s.Port,
Map = s.CurrentMap.Alias,
ClientCount = s.ClientNum,
MaxClients = s.MaxClients,
GameType = s.Gametype,
PlayerHistory = s.ClientHistory.ToArray(),
Players = s.GetClientsAsList()
.Select(p => new PlayerInfo()
var serverId = server.GetIdForServer().Result;
var clientHistory = _serverDataViewer.ClientHistoryAsync(_appConfig.MaxClientHistoryTime,
CancellationToken.None).Result
.FirstOrDefault(history => history.ServerId == serverId) ??
new ClientHistoryInfo
{
ServerId = serverId
};
var counts = clientHistory.ClientCounts?.AsEnumerable() ?? Enumerable.Empty<ClientCountSnapshot>();
if (server.ClientHistory.Count > 0)
{
Name = p.Name,
ClientId = p.ClientId,
Level = p.Level.ToLocalizedLevelName(),
LevelInt = (int)p.Level,
Tag = p.Tag,
ZScore = p.GetAdditionalProperty<EFClientStatistics>(IW4MAdmin.Plugins.Stats.Helpers.StatManager.CLIENT_STATS_KEY)?.ZScore
}).ToList(),
ChatHistory = s.ChatHistory.ToList(),
Online = !s.Throttled,
IPAddress = $"{(s.ResolvedIpEndPoint.Address.IsInternal() ? Program.Manager.ExternalIPAddress : s.IP)}:{s.Port}",
ConnectProtocolUrl = s.EventParser.URLProtocolFormat.FormatExt(s.ResolvedIpEndPoint.Address.IsInternal() ? Program.Manager.ExternalIPAddress : s.IP, s.Port)
}).ToList();
counts = counts.Union(server.ClientHistory
.Select(history => history.ToClientCountSnapshot()).Where(history =>
history.Time > clientHistory.ClientCounts.Last().Time));
}
serverInfo.Add(new ServerInfo()
{
Name = server.Hostname,
ID = server.EndPoint,
Port = server.Port,
Map = server.CurrentMap.Alias,
ClientCount = server.ClientNum,
MaxClients = server.MaxClients,
GameType = server.Gametype,
PlayerHistory = server.ClientHistory.ToArray(),
Players = server.GetClientsAsList()
.Select(p => new PlayerInfo()
{
Name = p.Name,
ClientId = p.ClientId,
Level = p.Level.ToLocalizedLevelName(),
LevelInt = (int) p.Level,
Tag = p.Tag,
ZScore = p.GetAdditionalProperty<EFClientStatistics>(IW4MAdmin.Plugins.Stats.Helpers
.StatManager
.CLIENT_STATS_KEY)?.ZScore
}).ToList(),
ChatHistory = server.ChatHistory.ToList(),
ClientCountHistory =
counts.Where(history => history.Time >= DateTime.UtcNow - _appConfig.MaxClientHistoryTime)
.ToList(),
Online = !server.Throttled,
IPAddress =
$"{(server.ResolvedIpEndPoint.Address.IsInternal() ? Program.Manager.ExternalIPAddress : server.IP)}:{server.Port}",
ConnectProtocolUrl = server.EventParser.URLProtocolFormat.FormatExt(
server.ResolvedIpEndPoint.Address.IsInternal() ? Program.Manager.ExternalIPAddress : server.IP,
server.Port)
});
}
return View("_List", serverInfo);
}
}
}
}

View File

@ -50,5 +50,8 @@
</div>
<div class="row server-history mb-4">
<div class="server-history-row" id="server_history_@Model.ID" data-serverid="@Model.ID" data-clienthistory='@Html.Raw(Json.Serialize(Model.PlayerHistory))' data-online="@Model.Online"></div>
<div class="server-history-row" id="server_history_@Model.ID" data-serverid="@Model.ID"
data-clienthistory='@Html.Raw(Json.Serialize(Model.PlayerHistory))'
data-clienthistory-ex='@Html.Raw(Json.Serialize(Model.ClientCountHistory))'
data-online="@Model.Online"></div>
</div>

View File

@ -2,7 +2,8 @@
///////////////////////////////////////
// thanks to canvasjs :(
playerHistory.forEach(function (item, i) {
playerHistory[i].x = new Date(playerHistory[i].x);
playerHistory[i].x = new Date(playerHistory[i].timeString);
playerHistory[i].y = playerHistory[i].clientCount;
});
return new CanvasJS.Chart(`server_history_${i}`, {
@ -84,7 +85,7 @@ $(document).ready(function () {
});
$('.server-history-row').each(function (index, element) {
let clientHistory = $(this).data('clienthistory');
let clientHistory = $(this).data('clienthistory-ex');
let serverId = $(this).data('serverid');
let maxClients = parseInt($('#server_header_' + serverId + ' .server-maxclients').text());
let primaryColor = $('title').css('background-color');