Compare commits

...

15 Commits

31 changed files with 575 additions and 194 deletions

View File

@ -0,0 +1,52 @@
using System;
using System.Threading.Tasks;
using Data.Models.Client;
using IW4MAdmin.Application.Meta;
using SharedLibraryCore;
using SharedLibraryCore.Commands;
using SharedLibraryCore.Configuration;
using SharedLibraryCore.Dtos.Meta.Responses;
using SharedLibraryCore.Interfaces;
namespace IW4MAdmin.Application.Commands;
public class AddClientNoteCommand : Command
{
private readonly IMetaServiceV2 _metaService;
public AddClientNoteCommand(CommandConfiguration config, ITranslationLookup layout, IMetaServiceV2 metaService) : base(config, layout)
{
Name = "addnote";
Description = _translationLookup["COMMANDS_ADD_CLIENT_NOTE_DESCRIPTION"];
Alias = "an";
Permission = EFClient.Permission.Moderator;
RequiresTarget = true;
Arguments = new[]
{
new CommandArgument
{
Name = _translationLookup["COMMANDS_ARGS_PLAYER"],
Required = true
},
new CommandArgument
{
Name = _translationLookup["COMMANDS_ARGS_NOTE"],
Required = false
}
};
_metaService = metaService;
}
public override async Task ExecuteAsync(GameEvent gameEvent)
{
var note = new ClientNoteMetaResponse
{
Note = gameEvent.Data?.Trim(),
OriginEntityId = gameEvent.Origin.ClientId,
ModifiedDate = DateTime.UtcNow
};
await _metaService.SetPersistentMetaValue("ClientNotes", note, gameEvent.Target.ClientId);
gameEvent.Origin.Tell(_translationLookup["COMMANDS_ADD_CLIENT_NOTE_SUCCESS"]);
}
}

View File

@ -24,7 +24,7 @@ namespace IW4MAdmin.Application.Commands
Name = "readmessage"; Name = "readmessage";
Description = _translationLookup["COMMANDS_READ_MESSAGE_DESC"]; Description = _translationLookup["COMMANDS_READ_MESSAGE_DESC"];
Alias = "rm"; Alias = "rm";
Permission = EFClient.Permission.Flagged; Permission = EFClient.Permission.User;
_contextFactory = contextFactory; _contextFactory = contextFactory;
_logger = logger; _logger = logger;

View File

@ -9,6 +9,7 @@ using SharedLibraryCore.Helpers;
using SharedLibraryCore.Interfaces; using SharedLibraryCore.Interfaces;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -771,17 +772,28 @@ namespace IW4MAdmin
else if (E.Type == GameEvent.EventType.MetaUpdated) else if (E.Type == GameEvent.EventType.MetaUpdated)
{ {
if (E.Extra is "PersistentStatClientId" && int.TryParse(E.Data, out var persistentClientId)) if (E.Extra is "PersistentClientGuid")
{ {
var penalties = await Manager.GetPenaltyService().GetActivePenaltiesByClientId(persistentClientId); var parts = E.Data.Split(",");
var banPenalty = penalties.FirstOrDefault(penalty => penalty.Type == EFPenalty.PenaltyType.Ban);
if (parts.Length == 2 && int.TryParse(parts[0], out var high) &&
int.TryParse(parts[1], out var low))
{
var guid = long.Parse(high.ToString("X") + low.ToString("X"), NumberStyles.HexNumber);
var penalties = await Manager.GetPenaltyService()
.GetActivePenaltiesByIdentifier(null, guid, (Reference.Game)GameName);
var banPenalty =
penalties.FirstOrDefault(penalty => penalty.Type == EFPenalty.PenaltyType.Ban);
if (banPenalty is not null && E.Origin.Level != Permission.Banned) if (banPenalty is not null && E.Origin.Level != Permission.Banned)
{ {
ServerLogger.LogInformation( ServerLogger.LogInformation(
"Banning {Client} as they have have provided a persistent clientId of {PersistentClientId}, which is banned", "Banning {Client} as they have have provided a persistent clientId of {PersistentClientId}, which is banned",
E.Origin.ToString(), persistentClientId); E.Origin.ToString(), guid);
E.Origin.Ban(loc["SERVER_BAN_EVADE"].FormatExt(persistentClientId), Utilities.IW4MAdminClient(this), true); E.Origin.Ban(loc["SERVER_BAN_EVADE"].FormatExt(guid),
Utilities.IW4MAdminClient(this), true);
}
} }
} }
} }
@ -1289,28 +1301,17 @@ namespace IW4MAdmin
this.GamePassword = gamePassword.Value; this.GamePassword = gamePassword.Value;
UpdateMap(mapname); UpdateMap(mapname);
if (RconParser.CanGenerateLogPath) if (RconParser.CanGenerateLogPath && string.IsNullOrEmpty(ServerConfig.ManualLogPath))
{ {
bool needsRestart = false;
if (logsync.Value == 0) if (logsync.Value == 0)
{ {
await this.SetDvarAsync("g_logsync", 2, Manager.CancellationToken); // set to 2 for continous in other games, clamps to 1 for IW4 await this.SetDvarAsync("g_logsync", 2, Manager.CancellationToken); // set to 2 for continous in other games, clamps to 1 for IW4
needsRestart = true;
} }
if (string.IsNullOrWhiteSpace(logfile.Value)) if (string.IsNullOrWhiteSpace(logfile.Value))
{ {
logfile.Value = "games_mp.log"; logfile.Value = "games_mp.log";
await this.SetDvarAsync("g_log", logfile.Value, Manager.CancellationToken); await this.SetDvarAsync("g_log", logfile.Value, Manager.CancellationToken);
needsRestart = true;
}
if (needsRestart)
{
// disabling this for the time being
/*Logger.WriteWarning("Game log file not properly initialized, restarting map...");
await this.ExecuteCommandAsync("map_restart");*/
} }
// this DVAR isn't set until the a map is loaded // this DVAR isn't set until the a map is loaded

View File

@ -33,7 +33,7 @@ namespace IW4MAdmin.Application.Misc
builder.Append(header); builder.Append(header);
builder.Append(config.NoticeLineSeparator); builder.Append(config.NoticeLineSeparator);
// build the reason // build the reason
var reason = _transLookup["GAME_MESSAGE_PENALTY_REASON"].FormatExt(penalty.Offense); var reason = _transLookup["GAME_MESSAGE_PENALTY_REASON"].FormatExt(penalty.Offense.FormatMessageForEngine(config));
if (isNewLineSeparator) if (isNewLineSeparator)
{ {

View File

@ -7,8 +7,6 @@ namespace Data.Models.Client.Stats
{ {
public class EFClientRankingHistory: AuditFields public class EFClientRankingHistory: AuditFields
{ {
public const int MaxRankingCount = 1728;
[Key] [Key]
public long ClientRankingHistoryId { get; set; } public long ClientRankingHistoryId { get; set; }

View File

@ -29,8 +29,9 @@ onPlayerConnect( player )
for( ;; ) for( ;; )
{ {
level waittill( "connected", player ); level waittill( "connected", player );
player setClientDvar("cl_autorecord", 1); player setClientDvars( "cl_autorecord", 1,
player setClientDvar("cl_demosKeep", 200); "cl_demosKeep", 200 );
player thread waitForFrameThread(); player thread waitForFrameThread();
player thread waitForAttack(); player thread waitForAttack();
} }
@ -60,7 +61,7 @@ getHttpString( url )
runRadarUpdates() runRadarUpdates()
{ {
interval = int(getDvar("sv_printradar_updateinterval")); interval = getDvarInt( "sv_printradar_updateinterval", 500 );
for ( ;; ) for ( ;; )
{ {
@ -191,7 +192,7 @@ waitForAdditionalAngles( logString, beforeFrameCount, afterFrameCount )
i++; i++;
} }
lastAttack = int(getTime()) - int(self.lastAttackTime); lastAttack = getTime() - self.lastAttackTime;
isAlive = isAlive(self); isAlive = isAlive(self);
logPrint(logString + ";" + anglesStr + ";" + isAlive + ";" + lastAttack + "\n" ); logPrint(logString + ";" + anglesStr + ";" + isAlive + ";" + lastAttack + "\n" );

View File

@ -53,7 +53,7 @@ waitForAttack()
runRadarUpdates() runRadarUpdates()
{ {
interval = int(getDvar("sv_printradar_updateinterval")); interval = getDvarInt( "sv_printradar_updateinterval", 500 );
for ( ;; ) for ( ;; )
{ {
@ -183,7 +183,7 @@ waitForAdditionalAngles( logString, beforeFrameCount, afterFrameCount )
i++; i++;
} }
lastAttack = int(getTime()) - int(self.lastAttackTime); lastAttack = getTime() - self.lastAttackTime;
isAlive = isAlive(self); isAlive = isAlive(self);
logPrint(logString + ";" + anglesStr + ";" + isAlive + ";" + lastAttack + "\n" ); logPrint(logString + ";" + anglesStr + ";" + isAlive + ";" + lastAttack + "\n" );

View File

@ -60,7 +60,7 @@ waitForAttack()
runRadarUpdates() runRadarUpdates()
{ {
interval = int(getDvar("sv_printradar_updateinterval")); interval = getDvarInt( "sv_printradar_updateinterval" );
for ( ;; ) for ( ;; )
{ {
@ -190,7 +190,7 @@ waitForAdditionalAngles( logString, beforeFrameCount, afterFrameCount )
i++; i++;
} }
lastAttack = int(getTime()) - int(self.lastAttackTime); lastAttack = getTime() - self.lastAttackTime;
isAlive = isAlive(self); isAlive = isAlive(self);
logPrint(logString + ";" + anglesStr + ";" + isAlive + ";" + lastAttack + "\n" ); logPrint(logString + ";" + anglesStr + ";" + isAlive + ";" + lastAttack + "\n" );

View File

@ -59,7 +59,7 @@ init()
OnPlayerConnect() OnPlayerConnect()
{ {
level endon ( "disconnect" ); level endon ( "game_ended" );
for ( ;; ) for ( ;; )
{ {
@ -104,7 +104,7 @@ OnPlayerSpawned()
OnPlayerDisconnect() OnPlayerDisconnect()
{ {
level endon ( "disconnect" ); self endon ( "disconnect" );
for ( ;; ) for ( ;; )
{ {
@ -139,8 +139,6 @@ OnPlayerJoinedSpectators()
OnGameEnded() OnGameEnded()
{ {
level endon ( "disconnect" );
for ( ;; ) for ( ;; )
{ {
level waittill( "game_ended" ); level waittill( "game_ended" );
@ -167,24 +165,29 @@ DisplayWelcomeData()
SetPersistentData() SetPersistentData()
{ {
storedClientId = self GetPlayerData( "bests", "none" ); guidHigh = self GetPlayerData( "bests", "none" );
guidLow = self GetPlayerData( "awards", "none" );
persistentGuid = guidHigh + "," + guidLow;
if ( storedClientId != 0 ) if ( guidHigh != 0 && guidLow != 0)
{ {
if ( level.iw4adminIntegrationDebug == 1 ) if ( level.iw4adminIntegrationDebug == 1 )
{ {
IPrintLn( "Uploading persistent client id " + storedClientId ); IPrintLn( "Uploading persistent guid " + persistentGuid );
} }
SetClientMeta( "PersistentStatClientId", storedClientId ); SetClientMeta( "PersistentClientGuid", persistentGuid );
} }
if ( level.iw4adminIntegrationDebug == 1 ) if ( level.iw4adminIntegrationDebug == 1 )
{ {
IPrintLn( "Persisting client id " + self.persistentClientId ); IPrintLn( "Persisting client guid " + persistentGuid );
} }
self SetPlayerData( "bests", "none", int( self.persistentClientId ) ); guid = self SplitGuid();
self SetPlayerData( "bests", "none", guid["high"] );
self SetPlayerData( "awards", "none", guid["low"] );
} }
PlayerConnectEvents() PlayerConnectEvents()
@ -228,8 +231,7 @@ PlayerTrackingOnInterval()
MonitorClientEvents() MonitorClientEvents()
{ {
level endon( "disconnect" ); level endon( "game_ended" );
self endon( "disconnect" );
for ( ;; ) for ( ;; )
{ {
@ -324,6 +326,107 @@ DecrementClientMeta( metaKey, decrementValue, clientId )
SetClientMeta( metaKey, decrementValue, clientId, "decrement" ); SetClientMeta( metaKey, decrementValue, clientId, "decrement" );
} }
SplitGuid()
{
guid = self GetGuid();
if ( isDefined( self.guid ) )
{
guid = self.guid;
}
firstPart = 0;
secondPart = 0;
stringLength = 17;
firstPartExp = 0;
secondPartExp = 0;
for ( i = stringLength - 1; i > 0; i-- )
{
char = GetSubStr( guid, i - 1, i );
if ( char == "" )
{
char = "0";
}
if ( i > stringLength / 2 )
{
value = GetIntForHexChar( char );
power = Pow( 16, secondPartExp );
secondPart = secondPart + ( value * power );
secondPartExp++;
}
else
{
value = GetIntForHexChar( char );
power = Pow( 16, firstPartExp );
firstPart = firstPart + ( value * power );
firstPartExp++;
}
}
split = [];
split["low"] = int( secondPart );
split["high"] = int( firstPart );
return split;
}
Pow( num, exponent )
{
result = 1;
while( exponent != 0 )
{
result = result * num;
exponent--;
}
return result;
}
GetIntForHexChar( char )
{
char = ToLower( char );
// generated by co-pilot because I can't be bothered to make it more "elegant"
switch( char )
{
case "0":
return 0;
case "1":
return 1;
case "2":
return 2;
case "3":
return 3;
case "4":
return 4;
case "5":
return 5;
case "6":
return 6;
case "7":
return 7;
case "8":
return 8;
case "9":
return 9;
case "a":
return 10;
case "b":
return 11;
case "c":
return 12;
case "d":
return 13;
case "e":
return 14;
case "f":
return 15;
default:
return 0;
}
}
GenerateJoinTeamString( isSpectator ) GenerateJoinTeamString( isSpectator )
{ {
team = self.team; team = self.team;
@ -476,7 +579,7 @@ MonitorBus()
QueueEvent( request, eventType, notifyEntity ) QueueEvent( request, eventType, notifyEntity )
{ {
level endon( "disconnect" ); level endon( "game_ended" );
start = GetTime(); start = GetTime();
maxWait = level.eventBus.timeout * 1000; // 30 seconds maxWait = level.eventBus.timeout * 1000; // 30 seconds
@ -511,6 +614,8 @@ QueueEvent( request, eventType, notifyEntity )
notifyEntity NotifyClientEventTimeout( eventType ); notifyEntity NotifyClientEventTimeout( eventType );
} }
SetDvar( level.eventBus.inVar, "" );
return; return;
} }

View File

@ -518,8 +518,8 @@ function onReceivedDvar(server, dvarName, dvarValue, success) {
logger.WriteDebug(`Key=${event.data['key']}, Value=${event.data['value']}, Direction=${event.data['direction']} ${token}`); logger.WriteDebug(`Key=${event.data['key']}, Value=${event.data['value']}, Direction=${event.data['direction']} ${token}`);
if (event.data['direction'] != null) { if (event.data['direction'] != null) {
event.data['direction'] = 'up' event.data['direction'] = 'up'
? metaService.IncrementPersistentMeta(event.data['key'], event.data['value'], clientId, token).GetAwaiter().GetResult() ? metaService.IncrementPersistentMeta(event.data['key'], parseInt(event.data['value']), clientId, token).GetAwaiter().GetResult()
: metaService.DecrementPersistentMeta(event.data['key'], event.data['value'], clientId, token).GetAwaiter().GetResult(); : metaService.DecrementPersistentMeta(event.data['key'], parseInt(event.data['value']), clientId, token).GetAwaiter().GetResult();
} else { } else {
metaService.SetPersistentMeta(event.data['key'], event.data['value'], clientId, token).GetAwaiter().GetResult(); metaService.SetPersistentMeta(event.data['key'], event.data['value'], clientId, token).GetAwaiter().GetResult();
} }

View File

@ -78,7 +78,7 @@ namespace Stats.Helpers
.Where(r => r.ClientId == clientInfo.ClientId) .Where(r => r.ClientId == clientInfo.ClientId)
.Where(r => r.ServerId == serverId) .Where(r => r.ServerId == serverId)
.Where(r => r.Ranking != null) .Where(r => r.Ranking != null)
.OrderByDescending(r => r.UpdatedDateTime) .OrderByDescending(r => r.CreatedDateTime)
.Take(250) .Take(250)
.ToListAsync(); .ToListAsync();

View File

@ -117,7 +117,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
return 0; return 0;
} }
public Expression<Func<EFClientRankingHistory, bool>> GetNewRankingFunc(int? clientId = null, long? serverId = null) public Expression<Func<EFClientRankingHistory, bool>> GetNewRankingFunc(int? clientId = null,
long? serverId = null)
{ {
return (ranking) => ranking.ServerId == serverId return (ranking) => ranking.ServerId == serverId
&& ranking.Client.Level != Data.Models.Client.EFClient.Permission.Banned && ranking.Client.Level != Data.Models.Client.EFClient.Permission.Banned
@ -138,6 +139,17 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.CountAsync(); .CountAsync();
} }
public class RankingSnapshot
{
public int ClientId { get; set; }
public string Name { get; set; }
public DateTime LastConnection { get; set; }
public double? PerformanceMetric { get; set; }
public double? ZScore { get; set; }
public int? Ranking { get; set; }
public DateTime CreatedDateTime { get; set; }
}
public async Task<List<TopStatsInfo>> GetNewTopStats(int start, int count, long? serverId = null) public async Task<List<TopStatsInfo>> GetNewTopStats(int start, int count, long? serverId = null)
{ {
await using var context = _contextFactory.CreateContext(false); await using var context = _contextFactory.CreateContext(false);
@ -150,23 +162,37 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.Take(count) .Take(count)
.ToListAsync(); .ToListAsync();
var rankings = await context.Set<EFClientRankingHistory>() var rankingsDict = new Dictionary<int, List<RankingSnapshot>>();
.Where(ranking => clientIdsList.Contains(ranking.ClientId))
.Where(ranking => ranking.ServerId == serverId) foreach (var clientId in clientIdsList)
.Select(ranking => new
{ {
ranking.ClientId, var eachRank = await context.Set<EFClientRankingHistory>()
ranking.Client.CurrentAlias.Name, .Where(ranking => ranking.ClientId == clientId)
ranking.Client.LastConnection, .Where(ranking => ranking.ServerId == serverId)
ranking.PerformanceMetric, .OrderByDescending(ranking => ranking.CreatedDateTime)
ranking.ZScore, .Select(ranking => new RankingSnapshot
ranking.Ranking, {
ranking.CreatedDateTime ClientId = ranking.ClientId,
Name = ranking.Client.CurrentAlias.Name,
LastConnection = ranking.Client.LastConnection,
PerformanceMetric = ranking.PerformanceMetric,
ZScore = ranking.ZScore,
Ranking = ranking.Ranking,
CreatedDateTime = ranking.CreatedDateTime
}) })
.Take(60)
.ToListAsync(); .ToListAsync();
var rankingsDict = rankings.GroupBy(rank => rank.ClientId) if (rankingsDict.ContainsKey(clientId))
.ToDictionary(rank => rank.Key, rank => rank.OrderBy(r => r.CreatedDateTime).ToList()); {
rankingsDict[clientId] = rankingsDict[clientId].Concat(eachRank).Distinct()
.OrderByDescending(ranking => ranking.CreatedDateTime).ToList();
}
else
{
rankingsDict.Add(clientId, eachRank);
}
}
var statsInfo = await context.Set<EFClientStatistics>() var statsInfo = await context.Set<EFClientStatistics>()
.Where(stat => clientIdsList.Contains(stat.ClientId)) .Where(stat => clientIdsList.Contains(stat.ClientId))
@ -187,7 +213,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.ToListAsync(); .ToListAsync();
var finished = statsInfo var finished = statsInfo
.OrderByDescending(stat => rankingsDict[stat.ClientId].Last().PerformanceMetric) .OrderByDescending(stat => rankingsDict[stat.ClientId].First().PerformanceMetric)
.Select((s, index) => new TopStatsInfo .Select((s, index) => new TopStatsInfo
{ {
ClientId = s.ClientId, ClientId = s.ClientId,
@ -195,24 +221,23 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
Deaths = s.Deaths, Deaths = s.Deaths,
Kills = s.Kills, Kills = s.Kills,
KDR = Math.Round(s.KDR, 2), KDR = Math.Round(s.KDR, 2),
LastSeen = (DateTime.UtcNow - (s.UpdatedAt ?? rankingsDict[s.ClientId].Last().LastConnection)) LastSeen = (DateTime.UtcNow - (s.UpdatedAt ?? rankingsDict[s.ClientId].First().LastConnection))
.HumanizeForCurrentCulture(1, TimeUnit.Week, TimeUnit.Second, ",", false), .HumanizeForCurrentCulture(1, TimeUnit.Week, TimeUnit.Second, ",", false),
LastSeenValue = DateTime.UtcNow - (s.UpdatedAt ?? rankingsDict[s.ClientId].Last().LastConnection), LastSeenValue = DateTime.UtcNow - (s.UpdatedAt ?? rankingsDict[s.ClientId].First().LastConnection),
Name = rankingsDict[s.ClientId].First().Name, Name = rankingsDict[s.ClientId].First().Name,
Performance = Math.Round(rankingsDict[s.ClientId].Last().PerformanceMetric ?? 0, 2), Performance = Math.Round(rankingsDict[s.ClientId].First().PerformanceMetric ?? 0, 2),
RatingChange = (rankingsDict[s.ClientId].First().Ranking - RatingChange = (rankingsDict[s.ClientId].Last().Ranking -
rankingsDict[s.ClientId].Last().Ranking) ?? 0, rankingsDict[s.ClientId].First().Ranking) ?? 0,
PerformanceHistory = rankingsDict[s.ClientId].Select(ranking => new PerformanceHistory PerformanceHistory = rankingsDict[s.ClientId].Select(ranking => new PerformanceHistory
{ Performance = ranking.PerformanceMetric ?? 0, OccurredAt = ranking.CreatedDateTime }) { Performance = ranking.PerformanceMetric ?? 0, OccurredAt = ranking.CreatedDateTime })
.ToList(), .ToList(),
TimePlayed = Math.Round(s.TotalTimePlayed / 3600.0, 1).ToString("#,##0"), TimePlayed = Math.Round(s.TotalTimePlayed / 3600.0, 1).ToString("#,##0"),
TimePlayedValue = TimeSpan.FromSeconds(s.TotalTimePlayed), TimePlayedValue = TimeSpan.FromSeconds(s.TotalTimePlayed),
Ranking = index + start + 1, Ranking = index + start + 1,
ZScore = rankingsDict[s.ClientId].Last().ZScore, ZScore = rankingsDict[s.ClientId].First().ZScore,
ServerId = serverId ServerId = serverId
}) })
.OrderBy(r => r.Ranking) .OrderBy(r => r.Ranking)
.Take(60)
.ToList(); .ToList();
return finished; return finished;
@ -675,11 +700,11 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
TimeSinceLastAttack = long.Parse(lastAttackTime), TimeSinceLastAttack = long.Parse(lastAttackTime),
GameName = (int)attacker.CurrentServer.GameName GameName = (int)attacker.CurrentServer.GameName
}; };
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.LogError(ex, "Could not parse script hit data. Damage={Damage}, TimeOffset={Offset}, TimeSinceLastAttack={LastAttackTime}", _log.LogError(ex,
"Could not parse script hit data. Damage={Damage}, TimeOffset={Offset}, TimeSinceLastAttack={LastAttackTime}",
damage, offset, lastAttackTime); damage, offset, lastAttackTime);
return; return;
@ -1267,7 +1292,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
if (performances.Any(performance => performance.TimePlayed >= minPlayTime)) if (performances.Any(performance => performance.TimePlayed >= minPlayTime))
{ {
var aggregateZScore = performances.WeightValueByPlaytime(nameof(EFClientStatistics.ZScore), minPlayTime); var aggregateZScore =
performances.WeightValueByPlaytime(nameof(EFClientStatistics.ZScore), minPlayTime);
int? aggregateRanking = await context.Set<EFClientStatistics>() int? aggregateRanking = await context.Set<EFClientStatistics>()
.Where(stat => stat.ClientId != clientId) .Where(stat => stat.ClientId != clientId)
@ -1322,16 +1348,22 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
context.Update(mostRecent); context.Update(mostRecent);
} }
if (totalRankingEntries > EFClientRankingHistory.MaxRankingCount) const int maxRankingCount = 1728; // 60 / 2.5 * 24 * 3 ( 3 days at sample every 2.5 minutes)
if (totalRankingEntries > maxRankingCount)
{ {
var lastRating = await context.Set<EFClientRankingHistory>() var lastRating = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId) .Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId) .Where(r => r.ServerId == serverId)
.OrderBy(r => r.CreatedDateTime) .OrderBy(r => r.CreatedDateTime)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (lastRating is not null)
{
context.Remove(lastRating); context.Remove(lastRating);
} }
} }
}
/// <summary> /// <summary>
/// Performs the incrementation of kills and deaths for client statistics /// Performs the incrementation of kills and deaths for client statistics
@ -1455,7 +1487,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
if (double.IsNaN(clientStats.SPM) || double.IsNaN(clientStats.Skill)) if (double.IsNaN(clientStats.SPM) || double.IsNaN(clientStats.Skill))
{ {
_log.LogWarning("clientStats SPM/Skill NaN {@killInfo}", _log.LogWarning("clientStats SPM/Skill NaN {@killInfo}",
new {killSPM = killSpm, KDRWeight, totalPlayTime, SPMAgainstPlayWeight, clientStats, scoreDifference}); new
{
killSPM = killSpm, KDRWeight, totalPlayTime, SPMAgainstPlayWeight, clientStats, scoreDifference
});
clientStats.SPM = 0; clientStats.SPM = 0;
clientStats.Skill = 0; clientStats.Skill = 0;
} }

View File

@ -0,0 +1,13 @@
using System;
using System.Text.Json.Serialization;
namespace SharedLibraryCore.Dtos.Meta.Responses;
public class ClientNoteMetaResponse
{
public string Note { get; set; }
public int OriginEntityId { get; set; }
[JsonIgnore]
public string OriginEntityName { get; set; }
public DateTime ModifiedDate { get; set; }
}

View File

@ -33,5 +33,6 @@ namespace SharedLibraryCore.Dtos
public string ConnectProtocolUrl { get;set; } public string ConnectProtocolUrl { get;set; }
public string CurrentServerName { get; set; } public string CurrentServerName { get; set; }
public IGeoLocationResult GeoLocationInfo { get; set; } public IGeoLocationResult GeoLocationInfo { get; set; }
public ClientNoteMetaResponse NoteMeta { get; set; }
} }
} }

View File

@ -938,6 +938,14 @@ namespace SharedLibraryCore.Services
return clientList; return clientList;
} }
public async Task<string> GetClientNameById(int clientId)
{
await using var context = _contextFactory.CreateContext();
var match = await context.Clients.Select(client => new { client.CurrentAlias.Name, client.ClientId })
.FirstOrDefaultAsync(client => client.ClientId == clientId);
return match?.Name;
}
#endregion #endregion
} }
} }

View File

@ -193,16 +193,6 @@ namespace SharedLibraryCore.Services
return await activePenaltiesIds.Select(ids => ids.Penalty).ToListAsync(); return await activePenaltiesIds.Select(ids => ids.Penalty).ToListAsync();
} }
public async Task<List<EFPenalty>> GetActivePenaltiesByClientId(int clientId)
{
await using var context = _contextFactory.CreateContext(false);
return await context.PenaltyIdentifiers
.Where(identifier => identifier.Penalty.Offender.ClientId == clientId)
.Select(identifier => identifier.Penalty)
.Where(Filter)
.ToListAsync();
}
public async Task<List<EFPenalty>> ActivePenaltiesByRecentIdentifiers(int linkId) public async Task<List<EFPenalty>> ActivePenaltiesByRecentIdentifiers(int linkId)
{ {
await using var context = _contextFactory.CreateContext(false); await using var context = _contextFactory.CreateContext(false);

View File

@ -2,17 +2,20 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Data.Models;
using Data.Models.Client; using Data.Models.Client;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SharedLibraryCore; using SharedLibraryCore;
using SharedLibraryCore.Commands; using SharedLibraryCore.Commands;
using SharedLibraryCore.Configuration; using SharedLibraryCore.Configuration;
using SharedLibraryCore.Dtos; using SharedLibraryCore.Dtos;
using SharedLibraryCore.Dtos.Meta.Responses;
using SharedLibraryCore.Helpers; using SharedLibraryCore.Helpers;
using SharedLibraryCore.Interfaces; using SharedLibraryCore.Interfaces;
using WebfrontCore.Permissions;
using WebfrontCore.ViewModels; using WebfrontCore.ViewModels;
namespace WebfrontCore.Controllers namespace WebfrontCore.Controllers
@ -20,6 +23,7 @@ namespace WebfrontCore.Controllers
public class ActionController : BaseController public class ActionController : BaseController
{ {
private readonly ApplicationConfiguration _appConfig; private readonly ApplicationConfiguration _appConfig;
private readonly IMetaServiceV2 _metaService;
private readonly string _banCommandName; private readonly string _banCommandName;
private readonly string _tempbanCommandName; private readonly string _tempbanCommandName;
private readonly string _unbanCommandName; private readonly string _unbanCommandName;
@ -29,11 +33,14 @@ namespace WebfrontCore.Controllers
private readonly string _flagCommandName; private readonly string _flagCommandName;
private readonly string _unflagCommandName; private readonly string _unflagCommandName;
private readonly string _setLevelCommandName; private readonly string _setLevelCommandName;
private readonly string _setClientTagCommandName;
private readonly string _addClientNoteCommandName;
public ActionController(IManager manager, IEnumerable<IManagerCommand> registeredCommands, public ActionController(IManager manager, IEnumerable<IManagerCommand> registeredCommands,
ApplicationConfiguration appConfig) : base(manager) ApplicationConfiguration appConfig, IMetaServiceV2 metaService) : base(manager)
{ {
_appConfig = appConfig; _appConfig = appConfig;
_metaService = metaService;
foreach (var cmd in registeredCommands) foreach (var cmd in registeredCommands)
{ {
@ -69,6 +76,12 @@ namespace WebfrontCore.Controllers
case "OfflineMessageCommand": case "OfflineMessageCommand":
_offlineMessageCommandName = cmd.Name; _offlineMessageCommandName = cmd.Name;
break; break;
case "SetClientTagCommand":
_setClientTagCommandName = cmd.Name;
break;
case "AddClientNoteCommand":
_addClientNoteCommandName = cmd.Name;
break;
} }
} }
} }
@ -582,6 +595,103 @@ namespace WebfrontCore.Controllers
})); }));
} }
public async Task<IActionResult> SetClientTagForm(int id, CancellationToken token)
{
var tags = await _metaService.GetPersistentMetaValue<List<LookupValue<string>>>(EFMeta.ClientTagNameV2,
token) ?? new List<LookupValue<string>>();
var existingTag = await _metaService.GetPersistentMetaByLookup(EFMeta.ClientTagV2,
EFMeta.ClientTagNameV2, id, Manager.CancellationToken);
var info = new ActionInfo
{
ActionButtonLabel = Localization["WEBFRONT_ACTION_SET_CLIENT_TAG_SUBMIT"],
Name = Localization["WEBFRONT_PROFILE_CONTEXT_MENU_TAG"],
Inputs = new List<InputInfo>
{
new()
{
Name = "clientTag",
Type = "select",
Label = Localization["WEBFRONT_ACTION_SET_CLIENT_TAG_FORM_TAG"],
Values = tags.ToDictionary(
item => item.Value == existingTag?.Value ? $"!selected!{item.Value}" : item.Value,
item => item.Value)
}
},
Action = nameof(SetClientTag),
ShouldRefresh = true
};
return View("_ActionForm", info);
}
public async Task<IActionResult> SetClientTag(int targetId, string clientTag)
{
if (targetId <= 0 || string.IsNullOrWhiteSpace(clientTag))
{
return Json(new[]
{
new CommandResponseInfo
{
Response = Localization["WEBFRONT_ACTION_SET_CLIENT_TAG_NONE"]
}
});
}
var server = Manager.GetServers().First();
return await Task.FromResult(RedirectToAction("Execute", "Console", new
{
serverId = server.EndPoint,
command =
$"{_appConfig.CommandPrefix}{_setClientTagCommandName} @{targetId} {clientTag}"
}));
}
public async Task<IActionResult> AddClientNoteForm(int id)
{
var existingNote = await _metaService.GetPersistentMetaValue<ClientNoteMetaResponse>("ClientNotes", id);
var info = new ActionInfo
{
ActionButtonLabel = Localization["WEBFRONT_CONFIGURATION_BUTTON_SAVE"],
Name = Localization["WEBFRONT_PROFILE_CONTEXT_MENU_NOTE"],
Inputs = new List<InputInfo>
{
new()
{
Name = "note",
Label = Localization["WEBFRONT_ACTION_NOTE_FORM_NOTE"],
Value = existingNote?.Note,
Type = "textarea"
}
},
Action = nameof(AddClientNote),
ShouldRefresh = true
};
return View("_ActionForm", info);
}
public async Task<IActionResult> AddClientNote(int targetId, string note)
{
if (note?.Length > 350 || note?.Count(c => c == '\n') > 4)
{
return StatusCode(StatusCodes.Status400BadRequest, new[]
{
new CommandResponseInfo
{
Response = Localization["WEBFRONT_ACTION_NOTE_INVALID_LENGTH"]
}
});
}
var server = Manager.GetServers().First();
return await Task.FromResult(RedirectToAction("Execute", "Console", new
{
serverId = server.EndPoint,
command =
$"{_appConfig.CommandPrefix}{_addClientNoteCommandName} @{targetId} {note}"
}));
}
private Dictionary<string, string> GetPresetPenaltyReasons() => _appConfig.PresetPenaltyReasons.Values private Dictionary<string, string> GetPresetPenaltyReasons() => _appConfig.PresetPenaltyReasons.Values
.Concat(_appConfig.GlobalRules) .Concat(_appConfig.GlobalRules)
.Concat(_appConfig.Servers.SelectMany(server => server.Rules ?? Array.Empty<string>())) .Concat(_appConfig.Servers.SelectMany(server => server.Rules ?? Array.Empty<string>()))

View File

@ -12,6 +12,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Data.Models; using Data.Models;
using SharedLibraryCore.Services;
using Stats.Config; using Stats.Config;
using WebfrontCore.Permissions; using WebfrontCore.Permissions;
using WebfrontCore.ViewComponents; using WebfrontCore.ViewComponents;
@ -23,13 +24,15 @@ namespace WebfrontCore.Controllers
private readonly IMetaServiceV2 _metaService; private readonly IMetaServiceV2 _metaService;
private readonly StatsConfiguration _config; private readonly StatsConfiguration _config;
private readonly IGeoLocationService _geoLocationService; private readonly IGeoLocationService _geoLocationService;
private readonly ClientService _clientService;
public ClientController(IManager manager, IMetaServiceV2 metaService, StatsConfiguration config, public ClientController(IManager manager, IMetaServiceV2 metaService, StatsConfiguration config,
IGeoLocationService geoLocationService) : base(manager) IGeoLocationService geoLocationService, ClientService clientService) : base(manager)
{ {
_metaService = metaService; _metaService = metaService;
_config = config; _config = config;
_geoLocationService = geoLocationService; _geoLocationService = geoLocationService;
_clientService = clientService;
} }
[Obsolete] [Obsolete]
@ -53,18 +56,25 @@ namespace WebfrontCore.Controllers
{ {
_metaService.GetPersistentMetaByLookup(EFMeta.ClientTagV2, EFMeta.ClientTagNameV2, client.ClientId, _metaService.GetPersistentMetaByLookup(EFMeta.ClientTagV2, EFMeta.ClientTagNameV2, client.ClientId,
token), token),
_metaService.GetPersistentMeta("GravatarEmail", client.ClientId, token) _metaService.GetPersistentMeta("GravatarEmail", client.ClientId, token),
}; };
var persistentMeta = await Task.WhenAll(persistentMetaTask); var persistentMeta = await Task.WhenAll(persistentMetaTask);
var tag = persistentMeta[0]; var tag = persistentMeta[0];
var gravatar = persistentMeta[1]; var gravatar = persistentMeta[1];
var note = await _metaService.GetPersistentMetaValue<ClientNoteMetaResponse>("ClientNotes", client.ClientId,
token);
if (tag?.Value != null) if (tag?.Value != null)
{ {
client.SetAdditionalProperty(EFMeta.ClientTagV2, tag.Value); client.SetAdditionalProperty(EFMeta.ClientTagV2, tag.Value);
} }
if (!string.IsNullOrWhiteSpace(note?.Note))
{
note.OriginEntityName = await _clientService.GetClientNameById(note.OriginEntityId);
}
// even though we haven't set their level to "banned" yet // even though we haven't set their level to "banned" yet
// (ie they haven't reconnected with the infringing player identifier) // (ie they haven't reconnected with the infringing player identifier)
// we want to show them as banned as to not confuse people. // we want to show them as banned as to not confuse people.
@ -123,7 +133,8 @@ namespace WebfrontCore.Controllers
: ingameClient.CurrentServer.IP, : ingameClient.CurrentServer.IP,
ingameClient.CurrentServer.Port), ingameClient.CurrentServer.Port),
CurrentServerName = ingameClient?.CurrentServer?.Hostname, CurrentServerName = ingameClient?.CurrentServer?.Hostname,
GeoLocationInfo = await _geoLocationService.Locate(client.IPAddressString) GeoLocationInfo = await _geoLocationService.Locate(client.IPAddressString),
NoteMeta = string.IsNullOrWhiteSpace(note?.Note) ? null: note
}; };
var meta = await _metaService.GetRuntimeMeta<InformationResponse>(new ClientPaginationRequest var meta = await _metaService.GetRuntimeMeta<InformationResponse>(new ClientPaginationRequest

View File

@ -14,13 +14,13 @@ public enum WebfrontEntity
AuditPage, AuditPage,
RecentPlayersPage, RecentPlayersPage,
ProfilePage, ProfilePage,
AdminMenu AdminMenu,
ClientNote
} }
public enum WebfrontPermission public enum WebfrontPermission
{ {
Read, Read,
Create, Write,
Update,
Delete Delete
} }

View File

@ -25,12 +25,21 @@
@if (inputType == "select") @if (inputType == "select")
{ {
<select name="@input.Name" class="form-control" aria-label="@input.Name" aria-describedby="basic-addon-@input.Name"> <select name="@input.Name" class="form-control" aria-label="@input.Name" aria-describedby="basic-addon-@input.Name">
@foreach (var item in input.Values) @foreach (var (key, item) in input.Values)
{ {
<option value="@item.Key"> if (key.StartsWith("!selected!"))
<color-code value="@item.Value"></color-code> {
<option value="@key.Replace("!selected!", "")" selected>
<color-code value="@item"></color-code>
</option> </option>
} }
else
{
<option value="@key">
<color-code value="@item"></color-code>
</option>
}
}
</select> </select>
} }
@ -43,6 +52,11 @@
</div> </div>
} }
else if (inputType == "textarea")
{
<textarea name="@input.Name" class="form-control @(input.Required ? "required" : "")" placeholder="@input.Placeholder" aria-label="@input.Name" aria-describedby="basic-addon-@input.Name">@value</textarea>
}
else else
{ {
<input type="@inputType" name="@input.Name" value="@value" class="form-control @(input.Required ? "required" : "")" placeholder="@input.Placeholder" aria-label="@input.Name" aria-describedby="basic-addon-@input.Name"> <input type="@inputType" name="@input.Name" value="@value" class="form-control @(input.Required ? "required" : "")" placeholder="@input.Placeholder" aria-label="@input.Name" aria-describedby="basic-addon-@input.Name">

View File

@ -30,7 +30,7 @@
</td> </td>
<td> <td>
@info.Data @info.Data
<td> <td class="text-force-break font-weight-light">
@info.NewValue @info.NewValue
</td> </td>
<td class="text-right"> <td class="text-right">
@ -40,32 +40,36 @@
<!-- mobile --> <!-- mobile -->
<tr class="d-table-row d-lg-none d-flex bg-dark-dm bg-light-lm"> <tr class="d-table-row d-lg-none d-flex bg-dark-dm bg-light-lm">
<td class="bg-primary text-light text-right flex-grow-0"> <td class="bg-primary text-light text-right flex-grow-0 w-quarter d-flex flex-column">
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_TYPE"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_TYPE"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_ADMIN"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_ADMIN"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_NAME"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_NAME"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_ADMIN_AUDIT_LOG_INFO"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_ADMIN_AUDIT_LOG_INFO"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_ADMIN_AUDIT_LOG_CURRENT"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_ADMIN_AUDIT_LOG_CURRENT"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_ADMIN_AUDIT_LOG_TIME"]</div> <div class="mt-5 mb-5 mt-auto">@loc["WEBFRONT_ADMIN_AUDIT_LOG_TIME"]</div>
</td> </td>
<td> <td class="w-three-quarter d-flex flex-column">
<div class="mt-5 mb-5">@info.Action</div> <div class="mt-5 mb-5">@info.Action</div>
<div class="mt-5 mb-5">
<a asp-controller="Client" asp-action="Profile" asp-route-id="@info.OriginId" class="link-inverse"> <a asp-controller="Client" asp-action="Profile" asp-route-id="@info.OriginId" class="link-inverse">
<color-code value="@info.OriginName"></color-code> <color-code value="@info.OriginName"></color-code>
</a> </a>
</div>
@if (info.TargetId != null) @if (info.TargetId != null)
{ {
<a asp-controller="Client" asp-action="Profile" asp-route-id="@info.TargetId" class="mt-5 mb-5"> <div class="mt-5 mb-5">
<a asp-controller="Client" asp-action="Profile" asp-route-id="@info.TargetId">
<color-code value="@info.TargetName"></color-code> <color-code value="@info.TargetName"></color-code>
</a> </a>
</div>
} }
else else
{ {
<div class="mt-5 mb-5">&ndash;</div> <div class="mt-5 mb-5">&ndash;</div>
} }
<div class="mt-5 mb-5">@info.Data</div> <div class="mt-5 mb-5">@info.Data</div>
<div class="mt-5 mb-5">@info.NewValue</div> <div class="mt-5 mb-5 text-force-break">@info.NewValue</div>
<div class="mt-5 mb-5">@info.When</div> <div class="mt-5 mb-5 text-muted">@info.When.ToStandardFormat()</div>
</td> </td>
</tr> </tr>
} }

View File

@ -173,6 +173,27 @@
</div> </div>
</div> </div>
@if (!string.IsNullOrWhiteSpace(Model.NoteMeta?.Note))
{
<has-permission entity="ClientNote" required-permission="Read">
<div class="rounded border p-10 m-10 d-flex flex-column flex-md-row" style="border-style: dashed !important">
<i class="align-self-center oi oi-clipboard"></i>
<div class="align-self-center font-size-12 font-weight-light pl-10 pr-10">
@foreach (var line in Model.NoteMeta.Note.Split("\n"))
{
<div class="text-force-break">@line.TrimEnd('\r')</div>
}
<div class="mt-5">
<a asp-controller="Client" asp-action="Profile" asp-route-id="@Model.NoteMeta.OriginEntityId" class="no-decoration ">
<color-code value="@Model.NoteMeta.OriginEntityName"></color-code>
</a>
<span>&mdash; @Model.NoteMeta.ModifiedDate.HumanizeForCurrentCulture()</span>
</div>
</div>
</div>
</has-permission>
}
<div class="flex-fill d-flex justify-content-center justify-content-md-end mt-10 mt-md-0"> <div class="flex-fill d-flex justify-content-center justify-content-md-end mt-10 mt-md-0">
<!-- country flag --> <!-- country flag -->
<div id="ipGeoDropdown" class="dropdown with-arrow align-self-center"> <div id="ipGeoDropdown" class="dropdown with-arrow align-self-center">
@ -282,6 +303,27 @@
if (ViewBag.Authorized) if (ViewBag.Authorized)
{ {
menuItems.Items.Add(new SideContextMenuItem
{
Title = ViewBag.Localization["WEBFRONT_PROFILE_CONTEXT_MENU_TAG"],
IsButton = true,
Reference = "SetClientTag",
Icon = "oi-tag",
EntityId = Model.ClientId
});
if ((ViewBag.PermissionsSet as IEnumerable<string>).HasPermission(WebfrontEntity.ClientNote, WebfrontPermission.Write))
{
menuItems.Items.Add(new SideContextMenuItem
{
Title = ViewBag.Localization["WEBFRONT_PROFILE_CONTEXT_MENU_NOTE"],
IsButton = true,
Reference = "AddClientNote",
Icon = "oi-clipboard",
EntityId = Model.ClientId
});
}
menuItems.Items.Add(new SideContextMenuItem menuItems.Items.Add(new SideContextMenuItem
{ {
Title = ViewBag.Localization["WEBFRONT_PROFILE_CONTEXT_MENU_ACTION_MESSAGE"], Title = ViewBag.Localization["WEBFRONT_PROFILE_CONTEXT_MENU_ACTION_MESSAGE"],

View File

@ -124,11 +124,12 @@
var spm = Model.ServerId != null ? serverLegacyStat?.SPM.ToNumericalString() : Model.LegacyStats.WeightValueByPlaytime(nameof(EFClientStatistics.SPM), 0).ToNumericalString(); var spm = Model.ServerId != null ? serverLegacyStat?.SPM.ToNumericalString() : Model.LegacyStats.WeightValueByPlaytime(nameof(EFClientStatistics.SPM), 0).ToNumericalString();
var performanceHistory = Model.Ratings var performanceHistory = Model.Ratings
.OrderBy(rating => rating.CreatedDateTime)
.Select(rating => new PerformanceHistory { Performance = rating.PerformanceMetric, OccurredAt = rating.CreatedDateTime }); .Select(rating => new PerformanceHistory { Performance = rating.PerformanceMetric, OccurredAt = rating.CreatedDateTime });
if (performance != null) if (performance != null && performance != Model.Ratings.FirstOrDefault().PerformanceMetric)
{ {
performanceHistory = performanceHistory.Append(new PerformanceHistory { Performance = performance.Value, OccurredAt = DateTime.UtcNow }); performanceHistory = performanceHistory.Append(new PerformanceHistory { Performance = performance.Value, OccurredAt = Model.Ratings.FirstOrDefault()?.CreatedDateTime ?? DateTime.UtcNow });
} }
var score = allPerServer.Any() var score = allPerServer.Any()
@ -285,7 +286,7 @@
<!-- history graph --> <!-- history graph -->
@if (performanceHistory.Count() > 5) @if (performanceHistory.Count() > 5)
{ {
<div class="w-half m-auto ml-lg-auto " id="client_performance_history_container"> <div class="w-full w-lg-half m-auto ml-lg-auto" id="client_performance_history_container">
<canvas id="client_performance_history" data-history="@(JsonSerializer.Serialize(performanceHistory))"></canvas> <canvas id="client_performance_history" data-history="@(JsonSerializer.Serialize(performanceHistory))"></canvas>
</div> </div>
} }

View File

@ -1,5 +1,4 @@
@using IW4MAdmin.Plugins.Stats @using IW4MAdmin.Plugins.Stats
@using System.Text.Json.Serialization
@using System.Text.Json @using System.Text.Json
@model List<IW4MAdmin.Plugins.Stats.Web.Dtos.TopStatsInfo> @model List<IW4MAdmin.Plugins.Stats.Web.Dtos.TopStatsInfo>
@{ @{
@ -86,7 +85,7 @@
</div> </div>
</div> </div>
<div class="w-full w-md-half client-rating-graph pt-10 pb-10"> <div class="w-full w-md-half client-rating-graph pt-10 pb-10">
<canvas id="rating_history_@(stat.ClientId + "_" + stat.Id)" data-history="@(JsonSerializer.Serialize(stat.PerformanceHistory))"></canvas> <canvas id="rating_history_@(stat.ClientId + "_" + stat.Id)" data-history="@(JsonSerializer.Serialize(stat.PerformanceHistory.OrderBy(perf => perf.OccurredAt)))"></canvas>
</div> </div>
<div class="w-quarter align-self-center d-flex justify-content-center"> <div class="w-quarter align-self-center d-flex justify-content-center">
<img class="w-100 h-100" src="~/images/stats/ranks/rank_@(stat.ZScore.RankIconIndexForZScore()).png" alt="@stat.Performance"/> <img class="w-100 h-100" src="~/images/stats/ranks/rank_@(stat.ZScore.RankIconIndexForZScore()).png" alt="@stat.Performance"/>

View File

@ -41,14 +41,14 @@
<!-- mobile --> <!-- mobile -->
<tr class="d-table-row d-lg-none d-flex border-bottom"> <tr class="d-table-row d-lg-none d-flex border-bottom">
<td class="bg-primary text-light text-right"> <td class="bg-primary text-light text-right d-flex flex-column w-quarter">
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_NAME"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_NAME"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_TYPE"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_TYPE"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_OFFENSE"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_OFFENSE"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_ADMIN"]</div> <div class="mt-5 mb-5 mt-auto">@loc["WEBFRONT_PENALTY_TEMPLATE_ADMIN"]</div>
<div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_TIME"]</div> <div class="mt-5 mb-5">@loc["WEBFRONT_PENALTY_TEMPLATE_TIME"]</div>
</td> </td>
<td class="flex-fill"> <td class=" d-flex flex-column w-three-quarter">
<div class="mt-5 mb-5"> <div class="mt-5 mb-5">
<a asp-controller="Client" asp-action="Profile" asp-route-id="@Model.OffenderId" > <a asp-controller="Client" asp-action="Profile" asp-route-id="@Model.OffenderId" >
<color-code value="@Model.OffenderName"></color-code> <color-code value="@Model.OffenderName"></color-code>
@ -57,13 +57,13 @@
<div class="mt-5 mb-5 penalties-color-@Model.PenaltyTypeText.ToLower()"> <div class="mt-5 mb-5 penalties-color-@Model.PenaltyTypeText.ToLower()">
@ViewBag.Localization[$"WEBFRONT_PENALTY_{Model.PenaltyType.ToString().ToUpper()}"] @ViewBag.Localization[$"WEBFRONT_PENALTY_{Model.PenaltyType.ToString().ToUpper()}"]
</div> </div>
<div class="mt-5 mb-5"> <div class="mt-5 mb-5 text-force-break">
<color-code value="@($"{Model.Offense}{(ViewBag.Authorized ? Model.AdditionalPenaltyInformation : "")}")"></color-code> <color-code value="@($"{Model.Offense}{(ViewBag.Authorized ? Model.AdditionalPenaltyInformation : "")}")"></color-code>
</div> </div>
<a asp-controller="Client" asp-action="Profile" asp-route-id="@Model.PunisherId" class="mt-5 mb-5 @((!ViewBag.Authorized && ViewBag.EnablePrivilegedUserPrivacy) || Model.PunisherLevel == 0 ? "text-dark-lm text-light-dm" : "level-color-" + (int)Model.PunisherLevel)"> <a asp-controller="Client" asp-action="Profile" asp-route-id="@Model.PunisherId" class="mt-5 mb-5 @((!ViewBag.Authorized && ViewBag.EnablePrivilegedUserPrivacy) || Model.PunisherLevel == 0 ? "text-dark-lm text-light-dm" : "level-color-" + (int)Model.PunisherLevel)">
<color-code value="@Model.PunisherName"></color-code> <color-code value="@Model.PunisherName"></color-code>
</a> </a>
<div class="mt-5 mb-5"> <div class="mt-5 mb-5 text-muted">
@if (Model.Expired) @if (Model.Expired)
{ {
<span>@Model.TimePunishedString</span> <span>@Model.TimePunishedString</span>

View File

@ -59,7 +59,7 @@
{ {
var levelColorClass = !ViewBag.Authorized || client.client.LevelInt == 0 ? "text-light-dm text-dark-lm" : $"level-color-{client.client.LevelInt}"; var levelColorClass = !ViewBag.Authorized || client.client.LevelInt == 0 ? "text-light-dm text-dark-lm" : $"level-color-{client.client.LevelInt}";
<div class="d-flex @(clientIndex.index == 1 ? "justify-content-start flex-row-reverse" : "")"> <div class="d-flex @(clientIndex.index == 1 ? "justify-content-start flex-row-reverse" : "")">
<has-permission entity="AdminMenu" required-permission="Update"> <has-permission entity="AdminMenu" required-permission="Write">
<a href="#actionModal" class="profile-action" data-action="kick" data-action-id="@client.client.ClientId" aria-hidden="true"> <a href="#actionModal" class="profile-action" data-action="kick" data-action-id="@client.client.ClientId" aria-hidden="true">
<i class="oi oi-circle-x font-size-12 @levelColorClass"></i> <i class="oi oi-circle-x font-size-12 @levelColorClass"></i>
</a> </a>

View File

@ -31,7 +31,7 @@
<a href="@Model.ConnectProtocolUrl" class="text-light align-self-center server-join-button" title="@Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_HOME_JOIN_DESC"]"> <a href="@Model.ConnectProtocolUrl" class="text-light align-self-center server-join-button" title="@Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_HOME_JOIN_DESC"]">
<i class="oi oi-play-circle ml-5 mr-5"></i> <i class="oi oi-play-circle ml-5 mr-5"></i>
</a> </a>
<has-permission entity="AdminMenu" required-permission="Update"> <has-permission entity="AdminMenu" required-permission="Write">
<!-- send message button --> <!-- send message button -->
<a href="#actionModal" class="profile-action text-light align-self-center" data-action="chat" data-action-id="@Model.ID"> <a href="#actionModal" class="profile-action text-light align-self-center" data-action="chat" data-action-id="@Model.ID">
<i class="oi oi-chat ml-5 mr-5"></i> <i class="oi oi-chat ml-5 mr-5"></i>

View File

@ -12,7 +12,7 @@
"outputFileName": "wwwroot/js/global.min.js", "outputFileName": "wwwroot/js/global.min.js",
"inputFiles": [ "inputFiles": [
"wwwroot/lib/jquery/dist/jquery.js", "wwwroot/lib/jquery/dist/jquery.js",
"wwwroot/lib/moment.js/moment-with-locales.min.js", "wwwroot/lib/moment.js/min/moment-with-locales.min.js",
"wwwroot/lib/moment-timezone/moment-timezone.min.js", "wwwroot/lib/moment-timezone/moment-timezone.min.js",
"wwwroot/lib/chart.js/dist/Chart.bundle.min.js", "wwwroot/lib/chart.js/dist/Chart.bundle.min.js",
"wwwroot/lib/halfmoon/js/halfmoon.min.js", "wwwroot/lib/halfmoon/js/halfmoon.min.js",

View File

@ -84,10 +84,6 @@
border-bottom-color: var(--secondary-color); border-bottom-color: var(--secondary-color);
} }
#penalty_filter_selection {
border: none !important;
}
@-webkit-keyframes rotation { @-webkit-keyframes rotation {
from { from {
-webkit-transform: rotate(359deg); -webkit-transform: rotate(359deg);

View File

@ -26,7 +26,7 @@ function setupPerformanceGraph() {
} }
const chart = $('#client_performance_history'); const chart = $('#client_performance_history');
const container = $('#client_performance_history_container'); const container = $('#client_performance_history_container');
chart.attr('height', summary.height()); chart.attr('height', summary.height() * 1.5);
chart.attr('width', container.width()); chart.attr('width', container.width());
renderPerformanceChart(); renderPerformanceChart();
} }
@ -394,7 +394,7 @@ function renderPerformanceChart() {
position: 'right', position: 'right',
ticks: { ticks: {
precision: 0, precision: 0,
stepSize: 3, stepSize: max - min / 2,
callback: function (value, index, values) { callback: function (value, index, values) {
if (index === values.length - 1) { if (index === values.length - 1) {
return min; return min;

View File

@ -88,7 +88,7 @@ function getStatsChart(id) {
position: 'right', position: 'right',
ticks: { ticks: {
precision: 0, precision: 0,
stepSize: 3, stepSize: max - min / 2,
callback: function (value, index, values) { callback: function (value, index, values) {
if (index === values.length - 1) { if (index === values.length - 1) {
return min; return min;