Add max concurrent players over 24 hours badge to home

This commit is contained in:
RaidMax 2021-08-26 17:35:05 -05:00
parent 19a49504b8
commit a815bcbff5
34 changed files with 4904 additions and 47 deletions

View File

@ -792,8 +792,10 @@ namespace IW4MAdmin
}; };
} }
private async Task<long> GetIdForServer(Server server) public override async Task<long> GetIdForServer(Server server = null)
{ {
server ??= this;
if ($"{server.IP}:{server.Port.ToString()}" == "66.150.121.184:28965") if ($"{server.IP}:{server.Port.ToString()}" == "66.150.121.184:28965")
{ {
return 886229536; return 886229536;

View File

@ -185,6 +185,8 @@ namespace IW4MAdmin.Application
? WebfrontCore.Program.Init(ServerManager, serviceProvider, services, ServerManager.CancellationToken) ? WebfrontCore.Program.Init(ServerManager, serviceProvider, services, ServerManager.CancellationToken)
: Task.CompletedTask; : Task.CompletedTask;
var collectionService = serviceProvider.GetRequiredService<IServerDataCollector>();
// we want to run this one on a manual thread instead of letting the thread pool handle it, // we want to run this one on a manual thread instead of letting the thread pool handle it,
// because we can't exit early from waiting on console input, and it prevents us from restarting // because we can't exit early from waiting on console input, and it prevents us from restarting
var inputThread = new Thread(async () => await ReadConsoleInput(logger)); var inputThread = new Thread(async () => await ReadConsoleInput(logger));
@ -195,7 +197,8 @@ namespace IW4MAdmin.Application
ServerManager.Start(), ServerManager.Start(),
webfrontTask, webfrontTask,
serviceProvider.GetRequiredService<IMasterCommunication>() serviceProvider.GetRequiredService<IMasterCommunication>()
.RunUploadStatus(ServerManager.CancellationToken) .RunUploadStatus(ServerManager.CancellationToken),
collectionService.BeginCollectionAsync(cancellationToken: ServerManager.CancellationToken)
}; };
logger.LogDebug("Starting webfront and input tasks"); logger.LogDebug("Starting webfront and input tasks");
@ -410,6 +413,8 @@ namespace IW4MAdmin.Application
.AddSingleton<IHitInfoBuilder, HitInfoBuilder>() .AddSingleton<IHitInfoBuilder, HitInfoBuilder>()
.AddSingleton(typeof(ILookupCache<>), typeof(LookupCache<>)) .AddSingleton(typeof(ILookupCache<>), typeof(LookupCache<>))
.AddSingleton(typeof(IDataValueCache<,>), typeof(DataValueCache<,>)) .AddSingleton(typeof(IDataValueCache<,>), typeof(DataValueCache<,>))
.AddSingleton<IServerDataViewer, ServerDataViewer>()
.AddSingleton<IServerDataCollector, ServerDataCollector>()
.AddSingleton(translationLookup) .AddSingleton(translationLookup)
.AddDatabaseContextOptions(appConfig); .AddDatabaseContextOptions(appConfig);

View File

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Data.Abstractions;
using Data.Models;
using Data.Models.Client.Stats.Reference;
using Data.Models.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
using ILogger = Microsoft.Extensions.Logging.ILogger;
using SharedLibraryCore.Interfaces;
namespace IW4MAdmin.Application.Misc
{
/// <inheritdoc/>
public class ServerDataCollector : IServerDataCollector
{
private readonly ILogger _logger;
private readonly IManager _manager;
private readonly IDatabaseContextFactory _contextFactory;
private bool _inProgress;
private TimeSpan _period;
public ServerDataCollector(ILogger<ServerDataCollector> logger, IManager manager,
IDatabaseContextFactory contextFactory)
{
_logger = logger;
_manager = manager;
_contextFactory = contextFactory;
}
public async Task BeginCollectionAsync(TimeSpan? period = null, CancellationToken cancellationToken = default)
{
if (_inProgress)
{
throw new InvalidOperationException($"{nameof(ServerDataCollector)} is already collecting data");
}
_logger.LogDebug("Initializing data collection with {Name}", nameof(ServerDataCollector));
_inProgress = true;
_period = period ?? TimeSpan.FromMinutes(Utilities.IsDevelopment ? 1 : 5);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(_period, cancellationToken);
_logger.LogDebug("{Name} is collecting server data", nameof(ServerDataCollector));
var data = await BuildCollectionData(cancellationToken);
await SaveData(data, cancellationToken);
}
catch (TaskCanceledException)
{
_logger.LogInformation("Shutdown requested for {Name}", nameof(ServerDataCollector));
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error encountered collecting server data for {Name}",
nameof(ServerDataCollector));
}
}
}
private async Task<IEnumerable<EFServerSnapshot>> BuildCollectionData(CancellationToken token)
{
var data = await Task.WhenAll(_manager.GetServers()
.Select(async server => new EFServerSnapshot
{
CapturedAt = DateTime.UtcNow,
PeriodBlock = (int) (DateTimeOffset.UtcNow - DateTimeOffset.UnixEpoch).TotalMinutes,
ServerId = await server.GetIdForServer(),
MapId = await GetOrCreateMap(server.CurrentMap.Name, (Reference.Game) server.GameName, token),
ClientCount = server.ClientNum
}));
return data;
}
private async Task<int> GetOrCreateMap(string mapName, Reference.Game game, CancellationToken token)
{
await using var context = _contextFactory.CreateContext();
var existingMap =
await context.Maps.FirstOrDefaultAsync(map => map.Name == mapName && map.Game == game, token);
if (existingMap != null)
{
return existingMap.MapId;
}
var newMap = new EFMap
{
Name = mapName,
Game = game
};
context.Maps.Add(newMap);
await context.SaveChangesAsync(token);
return newMap.MapId;
}
private async Task SaveData(IEnumerable<EFServerSnapshot> snapshots, CancellationToken token)
{
await using var context = _contextFactory.CreateContext();
context.ServerSnapshots.AddRange(snapshots);
await context.SaveChangesAsync(token);
}
}
}

View File

@ -0,0 +1,99 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Data.Abstractions;
using Data.Models.Client;
using Data.Models.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
using SharedLibraryCore.Interfaces;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace IW4MAdmin.Application.Misc
{
/// <inheritdoc/>
public class ServerDataViewer : IServerDataViewer
{
private readonly ILogger _logger;
private readonly IDataValueCache<EFServerSnapshot, int> _snapshotCache;
private readonly IDataValueCache<EFClient, (int, int)> _serverStatsCache;
private readonly TimeSpan? _cacheTimeSpan =
Utilities.IsDevelopment ? TimeSpan.FromSeconds(1) : (TimeSpan?) null;
public ServerDataViewer(ILogger<ServerDataViewer> logger, IDataValueCache<EFServerSnapshot, int> snapshotCache,
IDataValueCache<EFClient, (int, int)> serverStatsCache)
{
_logger = logger;
_snapshotCache = snapshotCache;
_serverStatsCache = serverStatsCache;
}
public async Task<int> MaxConcurrentClientsAsync(long? serverId = null, TimeSpan? overPeriod = null,
CancellationToken token = default)
{
_snapshotCache.SetCacheItem(async (snapshots, cancellationToken) =>
{
var oldestEntry = overPeriod.HasValue
? DateTime.UtcNow - overPeriod.Value
: DateTime.UtcNow.AddDays(-1);
var maxClients = 0;
if (serverId != null)
{
maxClients = await snapshots.Where(snapshot => snapshot.ServerId == serverId)
.Where(snapshot => snapshot.CapturedAt >= oldestEntry)
.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))
.MaxAsync(cancellationToken) ?? 0;
}
_logger.LogDebug("Max concurrent clients since {Start} is {Clients}", oldestEntry, maxClients);
return maxClients;
}, nameof(MaxConcurrentClientsAsync), _cacheTimeSpan);
try
{
return await _snapshotCache.GetCacheItem(nameof(MaxConcurrentClientsAsync), token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not retrieve data for {Name}", nameof(MaxConcurrentClientsAsync));
return 0;
}
}
public async Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, CancellationToken token = default)
{
_serverStatsCache.SetCacheItem(async (set, cancellationToken) =>
{
var count = await set.CountAsync(cancellationToken);
var startOfPeriod =
DateTime.UtcNow.AddHours(-overPeriod?.TotalHours ?? -24);
var recentCount = await set.CountAsync(client => client.LastConnection >= startOfPeriod,
cancellationToken);
return (count, recentCount);
}, nameof(_serverStatsCache), _cacheTimeSpan);
try
{
return await _serverStatsCache.GetCacheItem(nameof(_serverStatsCache), token);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not retrieve data for {Name}", nameof(ClientCountsAsync));
return (0, 0);
}
}
}
}

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -6,7 +7,7 @@ namespace Data.Abstractions
{ {
public interface IDataValueCache<T, V> where T : class public interface IDataValueCache<T, V> where T : class
{ {
void SetCacheItem(Func<DbSet<T>, Task<V>> itemGetter, string keyName, TimeSpan? expirationTime = null); void SetCacheItem(Func<DbSet<T>, CancellationToken, Task<V>> itemGetter, string keyName, TimeSpan? expirationTime = null);
Task<V> GetCacheItem(string keyName); Task<V> GetCacheItem(string keyName, CancellationToken token = default);
} }
} }

View File

@ -42,6 +42,7 @@ namespace Data.Context
#region MISC #region MISC
public DbSet<EFInboxMessage> InboxMessages { get; set; } public DbSet<EFInboxMessage> InboxMessages { get; set; }
public DbSet<EFServerSnapshot> ServerSnapshots { get;set; }
#endregion #endregion
@ -133,6 +134,7 @@ namespace Data.Context
modelBuilder.Entity<EFAlias>().ToTable("EFAlias"); modelBuilder.Entity<EFAlias>().ToTable("EFAlias");
modelBuilder.Entity<EFAliasLink>().ToTable("EFAliasLinks"); modelBuilder.Entity<EFAliasLink>().ToTable("EFAliasLinks");
modelBuilder.Entity<EFPenalty>().ToTable("EFPenalties"); modelBuilder.Entity<EFPenalty>().ToTable("EFPenalties");
modelBuilder.Entity<EFServerSnapshot>().ToTable(nameof(EFServerSnapshot));
Models.Configuration.StatsModelConfiguration.Configure(modelBuilder); Models.Configuration.StatsModelConfiguration.Configure(modelBuilder);

View File

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

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Data.Abstractions; using Data.Abstractions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -19,7 +20,7 @@ namespace Data.Helpers
public string Key { get; set; } public string Key { get; set; }
public DateTime LastRetrieval { get; set; } public DateTime LastRetrieval { get; set; }
public TimeSpan ExpirationTime { get; set; } public TimeSpan ExpirationTime { get; set; }
public Func<DbSet<T>, Task<V>> Getter { get; set; } public Func<DbSet<T>, CancellationToken, Task<V>> Getter { get; set; }
public V Value { get; set; } public V Value { get; set; }
public bool IsExpired => (DateTime.Now - LastRetrieval.Add(ExpirationTime)).TotalSeconds > 0; public bool IsExpired => (DateTime.Now - LastRetrieval.Add(ExpirationTime)).TotalSeconds > 0;
} }
@ -30,7 +31,7 @@ namespace Data.Helpers
_contextFactory = contextFactory; _contextFactory = contextFactory;
} }
public void SetCacheItem(Func<DbSet<T>, 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)) if (_cacheStates.ContainsKey(key))
{ {
@ -48,7 +49,7 @@ namespace Data.Helpers
_cacheStates.Add(key, state); _cacheStates.Add(key, state);
} }
public async Task<V> GetCacheItem(string keyName) public async Task<V> GetCacheItem(string keyName, CancellationToken cancellationToken = default)
{ {
if (!_cacheStates.ContainsKey(keyName)) if (!_cacheStates.ContainsKey(keyName))
{ {
@ -59,19 +60,19 @@ namespace Data.Helpers
if (state.IsExpired) if (state.IsExpired)
{ {
await RunCacheUpdate(state); await RunCacheUpdate(state, cancellationToken);
} }
return state.Value; return state.Value;
} }
private async Task RunCacheUpdate(CacheState state) private async Task RunCacheUpdate(CacheState state, CancellationToken token)
{ {
try try
{ {
await using var context = _contextFactory.CreateContext(false); await using var context = _contextFactory.CreateContext(false);
var set = context.Set<T>(); var set = context.Set<T>();
var value = await state.Getter(set); var value = await state.Getter(set, token);
state.Value = value; state.Value = value;
state.LastRetrieval = DateTime.Now; state.LastRetrieval = DateTime.Now;
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.Migrations.MySql
{
public partial class AddEFServerSnapshot : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EFServerSnapshot",
columns: table => new
{
ServerSnapshotId = table.Column<long>(nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Active = table.Column<bool>(nullable: false),
CapturedAt = table.Column<DateTime>(nullable: false),
PeriodBlock = table.Column<int>(nullable: false),
ServerId = table.Column<long>(nullable: false),
MapId = table.Column<int>(nullable: false),
ClientCount = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EFServerSnapshot", x => x.ServerSnapshotId);
table.ForeignKey(
name: "FK_EFServerSnapshot_EFMaps_MapId",
column: x => x.MapId,
principalTable: "EFMaps",
principalColumn: "MapId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EFServerSnapshot_EFServers_ServerId",
column: x => x.ServerId,
principalTable: "EFServers",
principalColumn: "ServerId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EFServerSnapshot_MapId",
table: "EFServerSnapshot",
column: "MapId");
migrationBuilder.CreateIndex(
name: "IX_EFServerSnapshot_ServerId",
table: "EFServerSnapshot",
column: "ServerId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EFServerSnapshot");
}
}
}

View File

@ -1001,6 +1001,39 @@ namespace Data.Migrations.MySql
b.ToTable("EFServers"); b.ToTable("EFServers");
}); });
modelBuilder.Entity("Data.Models.Server.EFServerSnapshot", b =>
{
b.Property<long>("ServerSnapshotId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("CapturedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ClientCount")
.HasColumnType("int");
b.Property<int>("MapId")
.HasColumnType("int");
b.Property<int>("PeriodBlock")
.HasColumnType("int");
b.Property<long>("ServerId")
.HasColumnType("bigint");
b.HasKey("ServerSnapshotId");
b.HasIndex("MapId");
b.HasIndex("ServerId");
b.ToTable("EFServerSnapshot");
});
modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b => modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b =>
{ {
b.Property<int>("StatisticId") b.Property<int>("StatisticId")
@ -1339,6 +1372,21 @@ namespace Data.Migrations.MySql
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Data.Models.Server.EFServerSnapshot", b =>
{
b.HasOne("Data.Models.Client.Stats.Reference.EFMap", "Map")
.WithMany()
.HasForeignKey("MapId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Data.Models.Server.EFServer", "Server")
.WithMany()
.HasForeignKey("ServerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b => modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b =>
{ {
b.HasOne("Data.Models.Server.EFServer", "Server") b.HasOne("Data.Models.Server.EFServer", "Server")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Data.Migrations.Postgresql
{
public partial class AddEFServerSnapshot : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EFServerSnapshot",
columns: table => new
{
ServerSnapshotId = table.Column<long>(nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn),
Active = table.Column<bool>(nullable: false),
CapturedAt = table.Column<DateTime>(nullable: false),
PeriodBlock = table.Column<int>(nullable: false),
ServerId = table.Column<long>(nullable: false),
MapId = table.Column<int>(nullable: false),
ClientCount = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EFServerSnapshot", x => x.ServerSnapshotId);
table.ForeignKey(
name: "FK_EFServerSnapshot_EFMaps_MapId",
column: x => x.MapId,
principalTable: "EFMaps",
principalColumn: "MapId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EFServerSnapshot_EFServers_ServerId",
column: x => x.ServerId,
principalTable: "EFServers",
principalColumn: "ServerId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EFServerSnapshot_MapId",
table: "EFServerSnapshot",
column: "MapId");
migrationBuilder.CreateIndex(
name: "IX_EFServerSnapshot_ServerId",
table: "EFServerSnapshot",
column: "ServerId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EFServerSnapshot");
}
}
}

View File

@ -1025,6 +1025,40 @@ namespace Data.Migrations.Postgresql
b.ToTable("EFServers"); b.ToTable("EFServers");
}); });
modelBuilder.Entity("Data.Models.Server.EFServerSnapshot", b =>
{
b.Property<long>("ServerSnapshotId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn);
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<DateTime>("CapturedAt")
.HasColumnType("timestamp without time zone");
b.Property<int>("ClientCount")
.HasColumnType("integer");
b.Property<int>("MapId")
.HasColumnType("integer");
b.Property<int>("PeriodBlock")
.HasColumnType("integer");
b.Property<long>("ServerId")
.HasColumnType("bigint");
b.HasKey("ServerSnapshotId");
b.HasIndex("MapId");
b.HasIndex("ServerId");
b.ToTable("EFServerSnapshot");
});
modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b => modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b =>
{ {
b.Property<int>("StatisticId") b.Property<int>("StatisticId")
@ -1365,6 +1399,21 @@ namespace Data.Migrations.Postgresql
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Data.Models.Server.EFServerSnapshot", b =>
{
b.HasOne("Data.Models.Client.Stats.Reference.EFMap", "Map")
.WithMany()
.HasForeignKey("MapId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Data.Models.Server.EFServer", "Server")
.WithMany()
.HasForeignKey("ServerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b => modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b =>
{ {
b.HasOne("Data.Models.Server.EFServer", "Server") b.HasOne("Data.Models.Server.EFServer", "Server")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.Migrations.Sqlite
{
public partial class AddEFServerSnapshot : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EFServerSnapshot",
columns: table => new
{
ServerSnapshotId = table.Column<long>(nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Active = table.Column<bool>(nullable: false),
CapturedAt = table.Column<DateTime>(nullable: false),
PeriodBlock = table.Column<int>(nullable: false),
ServerId = table.Column<long>(nullable: false),
MapId = table.Column<int>(nullable: false),
ClientCount = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EFServerSnapshot", x => x.ServerSnapshotId);
table.ForeignKey(
name: "FK_EFServerSnapshot_EFMaps_MapId",
column: x => x.MapId,
principalTable: "EFMaps",
principalColumn: "MapId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EFServerSnapshot_EFServers_ServerId",
column: x => x.ServerId,
principalTable: "EFServers",
principalColumn: "ServerId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EFServerSnapshot_MapId",
table: "EFServerSnapshot",
column: "MapId");
migrationBuilder.CreateIndex(
name: "IX_EFServerSnapshot_ServerId",
table: "EFServerSnapshot",
column: "ServerId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EFServerSnapshot");
}
}
}

View File

@ -1000,6 +1000,39 @@ namespace Data.Migrations.Sqlite
b.ToTable("EFServers"); b.ToTable("EFServers");
}); });
modelBuilder.Entity("Data.Models.Server.EFServerSnapshot", b =>
{
b.Property<long>("ServerSnapshotId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<DateTime>("CapturedAt")
.HasColumnType("TEXT");
b.Property<int>("ClientCount")
.HasColumnType("INTEGER");
b.Property<int>("MapId")
.HasColumnType("INTEGER");
b.Property<int>("PeriodBlock")
.HasColumnType("INTEGER");
b.Property<long>("ServerId")
.HasColumnType("INTEGER");
b.HasKey("ServerSnapshotId");
b.HasIndex("MapId");
b.HasIndex("ServerId");
b.ToTable("EFServerSnapshot");
});
modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b => modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b =>
{ {
b.Property<int>("StatisticId") b.Property<int>("StatisticId")
@ -1338,6 +1371,21 @@ namespace Data.Migrations.Sqlite
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Data.Models.Server.EFServerSnapshot", b =>
{
b.HasOne("Data.Models.Client.Stats.Reference.EFMap", "Map")
.WithMany()
.HasForeignKey("MapId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Data.Models.Server.EFServer", "Server")
.WithMany()
.HasForeignKey("ServerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b => modelBuilder.Entity("Data.Models.Server.EFServerStatistics", b =>
{ {
b.HasOne("Data.Models.Server.EFServer", "Server") b.HasOne("Data.Models.Server.EFServer", "Server")

View File

@ -0,0 +1,36 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Data.Models.Client.Stats.Reference;
namespace Data.Models.Server
{
public class EFServerSnapshot : SharedEntity
{
[Key]
public long ServerSnapshotId { get; set; }
public DateTime CapturedAt { get; set; }
/// <summary>
/// Specifies at which time block during a period the snapshot occured
/// | 1:00 | 1:05 | 1:10 |
/// | 5 minutes | 5 minutes | 5 minutes |
/// | 0 | 1 | 2 |
/// </summary>
public int PeriodBlock { get; set; }
[Required]
public long ServerId { get; set; }
[ForeignKey(nameof(ServerId))]
public EFServer Server { get; set; }
public int MapId { get; set; }
[ForeignKey(nameof(MapId))]
public EFMap Map { get; set; }
public int ClientCount { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ namespace SharedLibraryCore.Dtos
public int RecentClientCount { get; set; } public int RecentClientCount { get; set; }
public int TotalOccupiedClientSlots { get; set; } public int TotalOccupiedClientSlots { get; set; }
public int TotalAvailableClientSlots { get; set; } public int TotalAvailableClientSlots { get; set; }
public int MaxConcurrentClients { get; set; }
/// <summary> /// <summary>
/// specifies the game name filter /// specifies the game name filter

View File

@ -0,0 +1,17 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace SharedLibraryCore.Interfaces
{
public interface IServerDataCollector
{
/// <summary>
/// Begins to collection on servers for analytical purposes
/// </summary>
/// <param name="period">interval at which to collect data</param>
/// <param name="cancellationToken">Token</param>
/// <returns>Task</returns>
Task BeginCollectionAsync(TimeSpan? period = null, CancellationToken cancellationToken = default);
}
}

View File

@ -0,0 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace SharedLibraryCore.Interfaces
{
/// <summary>
/// Exposes methods to get analytical data about server(s)
/// </summary>
public interface IServerDataViewer
{
/// <summary>
/// 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="overPeriod">how far in the past to search</param>
/// <param name="token">CancellationToken</param>
/// <returns></returns>
Task<int> MaxConcurrentClientsAsync(long? serverId = 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="token"></param>
/// <returns></returns>
Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, CancellationToken token = default);
}
}

View File

@ -284,6 +284,8 @@ namespace SharedLibraryCore
} }
} }
public abstract Task<long> GetIdForServer(Server server = null);
// Objects // Objects
public IManager Manager { get; protected set; } public IManager Manager { get; protected set; }
[Obsolete] [Obsolete]

View File

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

View File

@ -1,15 +1,11 @@
using System; using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SharedLibraryCore; using SharedLibraryCore;
using SharedLibraryCore.Dtos; using SharedLibraryCore.Dtos;
using SharedLibraryCore.Interfaces; using SharedLibraryCore.Interfaces;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Data.Abstractions;
using Data.Models.Client;
using Data.Models.Client.Stats;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static SharedLibraryCore.Server; using static SharedLibraryCore.Server;
using ILogger = Microsoft.Extensions.Logging.ILogger; using ILogger = Microsoft.Extensions.Logging.ILogger;
@ -20,33 +16,25 @@ namespace WebfrontCore.Controllers
{ {
private readonly ITranslationLookup _translationLookup; private readonly ITranslationLookup _translationLookup;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IDataValueCache<EFClient, (int, int)> _serverStatsCache; private readonly IServerDataViewer _serverDataViewer;
private const string ServerStatKey = nameof(ServerStatKey);
public HomeController(ILogger<HomeController> logger, IManager manager, ITranslationLookup translationLookup, public HomeController(ILogger<HomeController> logger, IManager manager, ITranslationLookup translationLookup,
IDataValueCache<EFClient, (int, int)> serverStatsCache) : base(manager) IServerDataViewer serverDataViewer) : base(manager)
{ {
_logger = logger; _logger = logger;
_translationLookup = translationLookup; _translationLookup = translationLookup;
_serverStatsCache = serverStatsCache; _serverDataViewer = serverDataViewer;
_serverStatsCache.SetCacheItem(async set =>
{
var count = await set.CountAsync();
var startOfPeriod = DateTime.UtcNow.AddHours(-24);
var recentCount = await set.CountAsync(client => client.LastConnection >= startOfPeriod);
return (count, recentCount);
}, ServerStatKey);
} }
public async Task<IActionResult> Index(Game? game = null) public async Task<IActionResult> Index(Game? game = null, CancellationToken cancellationToken = default)
{ {
ViewBag.Description = Localization["WEBFRONT_DESCRIPTION_HOME"]; ViewBag.Description = Localization["WEBFRONT_DESCRIPTION_HOME"];
ViewBag.Title = Localization["WEBFRONT_HOME_TITLE"]; ViewBag.Title = Localization["WEBFRONT_HOME_TITLE"];
ViewBag.Keywords = Localization["WEBFRONT_KEWORDS_HOME"]; ViewBag.Keywords = Localization["WEBFRONT_KEWORDS_HOME"];
var servers = Manager.GetServers().Where(_server => !game.HasValue || _server.GameName == game); var servers = Manager.GetServers().Where(_server => !game.HasValue || _server.GameName == game);
var (count, recentCount) = await _serverStatsCache.GetCacheItem(ServerStatKey); var maxConcurrentClients = await _serverDataViewer.MaxConcurrentClientsAsync(token: cancellationToken);
var (count, recentCount) = await _serverDataViewer.ClientCountsAsync(token: cancellationToken);
var model = new IW4MAdminInfo() var model = new IW4MAdminInfo()
{ {
@ -54,6 +42,7 @@ namespace WebfrontCore.Controllers
TotalOccupiedClientSlots = servers.SelectMany(_server => _server.GetClientsAsList()).Count(), TotalOccupiedClientSlots = servers.SelectMany(_server => _server.GetClientsAsList()).Count(),
TotalClientCount = count, TotalClientCount = count,
RecentClientCount = recentCount, RecentClientCount = recentCount,
MaxConcurrentClients = maxConcurrentClients,
Game = game, Game = game,
ActiveServerGames = Manager.GetServers().Select(_server => _server.GameName).Distinct().ToArray() ActiveServerGames = Manager.GetServers().Select(_server => _server.GameName).Distinct().ToArray()
}; };

View File

@ -129,6 +129,7 @@ namespace WebfrontCore
.GetRequiredService<IConfigurationHandler<DefaultSettings>>()); .GetRequiredService<IConfigurationHandler<DefaultSettings>>());
services.AddSingleton(Program.ApplicationServiceProvider services.AddSingleton(Program.ApplicationServiceProvider
.GetRequiredService<IConfigurationHandler<StatsConfiguration>>()); .GetRequiredService<IConfigurationHandler<StatsConfiguration>>());
services.AddSingleton(Program.ApplicationServiceProvider.GetRequiredService<IServerDataViewer>());
} }
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -8,13 +8,16 @@
} }
} }
<div class="row mb-4 border-bottom border-top pt-3 pb-3 bg-dark"> <div class="row mb-4 border-bottom border-top pt-3 pb-3 bg-dark">
<div class="col-xl-4 col-12"> <div class="col-xl-3 col-12">
<div class="text-muted text-center text-xl-left">@Html.Raw(formatTranslation("WEBFRONT_HOME_CLIENTS_ONLINE", Model.TotalOccupiedClientSlots, Model.TotalAvailableClientSlots))</div> <div class="text-muted text-center text-xl-left">@Html.Raw(formatTranslation("WEBFRONT_HOME_CLIENTS_ONLINE", Model.TotalOccupiedClientSlots, Model.TotalAvailableClientSlots))</div>
</div> </div>
<div class="col-xl-4 col-12"> <div class="col-xl-3 col-12">
<div class="text-muted text-center text-xl-left">@Html.Raw(formatTranslation("WEBFRONT_HOME_MAX_CONCURRENT_CLIENTS", Model.MaxConcurrentClients.ToString("#,##0")))</div>
</div>
<div class="col-xl-3 col-12">
<div class="text-muted text-center">@Html.Raw(formatTranslation("WEBFRONT_HOME_RECENT_CLIENTS", Model.RecentClientCount.ToString("#,##0")))</div> <div class="text-muted text-center">@Html.Raw(formatTranslation("WEBFRONT_HOME_RECENT_CLIENTS", Model.RecentClientCount.ToString("#,##0")))</div>
</div> </div>
<div class="col-xl-4 col-12"> <div class="col-xl-3 col-12">
<div class="text-muted text-center text-xl-right">@Html.Raw(formatTranslation("WEBFRONT_HOME_TOTAL_CLIENTS", Model.TotalClientCount.ToString("#,##0")))</div> <div class="text-muted text-center text-xl-right">@Html.Raw(formatTranslation("WEBFRONT_HOME_TOTAL_CLIENTS", Model.TotalClientCount.ToString("#,##0")))</div>
</div> </div>
</div> </div>

View File

@ -97,6 +97,6 @@
</Target> </Target>
<Target Name="MyPreCompileTarget" BeforeTargets="Build"> <Target Name="MyPreCompileTarget" BeforeTargets="Build">
<Exec Command="dotnet bundle" /> <!--<Exec Command="dotnet bundle" />-->
</Target> </Target>
</Project> </Project>