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";
Description = _translationLookup["COMMANDS_READ_MESSAGE_DESC"];
Alias = "rm";
Permission = EFClient.Permission.Flagged;
Permission = EFClient.Permission.User;
_contextFactory = contextFactory;
_logger = logger;
@ -76,4 +76,4 @@ namespace IW4MAdmin.Application.Commands
}
}
}
}
}

View File

@ -9,6 +9,7 @@ using SharedLibraryCore.Helpers;
using SharedLibraryCore.Interfaces;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
@ -771,17 +772,28 @@ namespace IW4MAdmin
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 banPenalty = penalties.FirstOrDefault(penalty => penalty.Type == EFPenalty.PenaltyType.Ban);
var parts = E.Data.Split(",");
if (banPenalty is not null && E.Origin.Level != Permission.Banned)
if (parts.Length == 2 && int.TryParse(parts[0], out var high) &&
int.TryParse(parts[1], out var low))
{
ServerLogger.LogInformation(
"Banning {Client} as they have have provided a persistent clientId of {PersistentClientId}, which is banned",
E.Origin.ToString(), persistentClientId);
E.Origin.Ban(loc["SERVER_BAN_EVADE"].FormatExt(persistentClientId), Utilities.IW4MAdminClient(this), true);
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)
{
ServerLogger.LogInformation(
"Banning {Client} as they have have provided a persistent clientId of {PersistentClientId}, which is banned",
E.Origin.ToString(), guid);
E.Origin.Ban(loc["SERVER_BAN_EVADE"].FormatExt(guid),
Utilities.IW4MAdminClient(this), true);
}
}
}
}
@ -1289,28 +1301,17 @@ namespace IW4MAdmin
this.GamePassword = gamePassword.Value;
UpdateMap(mapname);
if (RconParser.CanGenerateLogPath)
if (RconParser.CanGenerateLogPath && string.IsNullOrEmpty(ServerConfig.ManualLogPath))
{
bool needsRestart = false;
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
needsRestart = true;
}
if (string.IsNullOrWhiteSpace(logfile.Value))
{
logfile.Value = "games_mp.log";
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

View File

@ -33,7 +33,7 @@ namespace IW4MAdmin.Application.Misc
builder.Append(header);
builder.Append(config.NoticeLineSeparator);
// 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)
{
@ -117,4 +117,4 @@ namespace IW4MAdmin.Application.Misc
return segments;
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -59,7 +59,7 @@ init()
OnPlayerConnect()
{
level endon ( "disconnect" );
level endon ( "game_ended" );
for ( ;; )
{
@ -67,7 +67,7 @@ OnPlayerConnect()
level.iw4adminIntegrationDebug = GetDvarInt( "sv_iw4madmin_integration_debug" );
if ( isDefined(player.pers["isBot"]) && player.pers["isBot"] )
if ( isDefined( player.pers["isBot"] ) && player.pers["isBot"] )
{
// we don't want to track bots
continue;
@ -104,7 +104,7 @@ OnPlayerSpawned()
OnPlayerDisconnect()
{
level endon ( "disconnect" );
self endon ( "disconnect" );
for ( ;; )
{
@ -139,8 +139,6 @@ OnPlayerJoinedSpectators()
OnGameEnded()
{
level endon ( "disconnect" );
for ( ;; )
{
level waittill( "game_ended" );
@ -167,24 +165,29 @@ DisplayWelcomeData()
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 )
{
IPrintLn( "Uploading persistent client id " + storedClientId );
IPrintLn( "Uploading persistent guid " + persistentGuid );
}
SetClientMeta( "PersistentStatClientId", storedClientId );
SetClientMeta( "PersistentClientGuid", persistentGuid );
}
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()
@ -228,8 +231,7 @@ PlayerTrackingOnInterval()
MonitorClientEvents()
{
level endon( "disconnect" );
self endon( "disconnect" );
level endon( "game_ended" );
for ( ;; )
{
@ -324,6 +326,107 @@ DecrementClientMeta( metaKey, decrementValue, clientId )
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 )
{
team = self.team;
@ -476,7 +579,7 @@ MonitorBus()
QueueEvent( request, eventType, notifyEntity )
{
level endon( "disconnect" );
level endon( "game_ended" );
start = GetTime();
maxWait = level.eventBus.timeout * 1000; // 30 seconds
@ -510,6 +613,8 @@ QueueEvent( request, eventType, notifyEntity )
{
notifyEntity NotifyClientEventTimeout( eventType );
}
SetDvar( level.eventBus.inVar, "" );
return;
}
@ -923,7 +1028,7 @@ GotoCoordImpl( data )
return;
}
position = ( int(data["x"]), int(data["y"]), int(data["z"]) );
position = ( int( data["x"] ), int( data["y"] ), int( data["z"]) );
self SetOrigin( position );
self IPrintLnBold( "Moved to " + "("+ position[0] + "," + position[1] + "," + position[2] + ")" );
}

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}`);
if (event.data['direction'] != null) {
event.data['direction'] = 'up'
? metaService.IncrementPersistentMeta(event.data['key'], event.data['value'], clientId, token).GetAwaiter().GetResult()
: metaService.DecrementPersistentMeta(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'], parseInt(event.data['value']), clientId, token).GetAwaiter().GetResult();
} else {
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.ServerId == serverId)
.Where(r => r.Ranking != null)
.OrderByDescending(r => r.UpdatedDateTime)
.OrderByDescending(r => r.CreatedDateTime)
.Take(250)
.ToListAsync();

View File

@ -86,7 +86,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
public async Task<int> GetClientOverallRanking(int clientId, long? serverId = null)
{
await using var context = _contextFactory.CreateContext(enableTracking: false);
if (_config.EnableAdvancedMetrics)
{
var clientRanking = await context.Set<EFClientRankingHistory>()
@ -117,7 +117,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
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
&& ranking.Client.Level != Data.Models.Client.EFClient.Permission.Banned
@ -138,6 +139,17 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.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)
{
await using var context = _contextFactory.CreateContext(false);
@ -150,24 +162,38 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.Take(count)
.ToListAsync();
var rankings = await context.Set<EFClientRankingHistory>()
.Where(ranking => clientIdsList.Contains(ranking.ClientId))
.Where(ranking => ranking.ServerId == serverId)
.Select(ranking => new
{
ranking.ClientId,
ranking.Client.CurrentAlias.Name,
ranking.Client.LastConnection,
ranking.PerformanceMetric,
ranking.ZScore,
ranking.Ranking,
ranking.CreatedDateTime
})
.ToListAsync();
var rankingsDict = new Dictionary<int, List<RankingSnapshot>>();
foreach (var clientId in clientIdsList)
{
var eachRank = await context.Set<EFClientRankingHistory>()
.Where(ranking => ranking.ClientId == clientId)
.Where(ranking => ranking.ServerId == serverId)
.OrderByDescending(ranking => ranking.CreatedDateTime)
.Select(ranking => new RankingSnapshot
{
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();
if (rankingsDict.ContainsKey(clientId))
{
rankingsDict[clientId] = rankingsDict[clientId].Concat(eachRank).Distinct()
.OrderByDescending(ranking => ranking.CreatedDateTime).ToList();
}
else
{
rankingsDict.Add(clientId, eachRank);
}
}
var rankingsDict = rankings.GroupBy(rank => rank.ClientId)
.ToDictionary(rank => rank.Key, rank => rank.OrderBy(r => r.CreatedDateTime).ToList());
var statsInfo = await context.Set<EFClientStatistics>()
.Where(stat => clientIdsList.Contains(stat.ClientId))
.Where(stat => stat.TimePlayed > 0)
@ -179,7 +205,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
ClientId = s.Key,
Kills = s.Sum(c => c.Kills),
Deaths = s.Sum(c => c.Deaths),
KDR = s.Sum(c => (c.Kills / (double) (c.Deaths == 0 ? 1 : c.Deaths)) * c.TimePlayed) /
KDR = s.Sum(c => (c.Kills / (double)(c.Deaths == 0 ? 1 : c.Deaths)) * c.TimePlayed) /
s.Sum(c => c.TimePlayed),
TotalTimePlayed = s.Sum(c => c.TimePlayed),
UpdatedAt = s.Max(c => c.UpdatedAt)
@ -187,7 +213,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.ToListAsync();
var finished = statsInfo
.OrderByDescending(stat => rankingsDict[stat.ClientId].Last().PerformanceMetric)
.OrderByDescending(stat => rankingsDict[stat.ClientId].First().PerformanceMetric)
.Select((s, index) => new TopStatsInfo
{
ClientId = s.ClientId,
@ -195,24 +221,23 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
Deaths = s.Deaths,
Kills = s.Kills,
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),
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,
Performance = Math.Round(rankingsDict[s.ClientId].Last().PerformanceMetric ?? 0, 2),
RatingChange = (rankingsDict[s.ClientId].First().Ranking -
rankingsDict[s.ClientId].Last().Ranking) ?? 0,
Performance = Math.Round(rankingsDict[s.ClientId].First().PerformanceMetric ?? 0, 2),
RatingChange = (rankingsDict[s.ClientId].Last().Ranking -
rankingsDict[s.ClientId].First().Ranking) ?? 0,
PerformanceHistory = rankingsDict[s.ClientId].Select(ranking => new PerformanceHistory
{ Performance = ranking.PerformanceMetric ?? 0, OccurredAt = ranking.CreatedDateTime })
.ToList(),
TimePlayed = Math.Round(s.TotalTimePlayed / 3600.0, 1).ToString("#,##0"),
TimePlayedValue = TimeSpan.FromSeconds(s.TotalTimePlayed),
Ranking = index + start + 1,
ZScore = rankingsDict[s.ClientId].Last().ZScore,
ZScore = rankingsDict[s.ClientId].First().ZScore,
ServerId = serverId
})
.OrderBy(r => r.Ranking)
.Take(60)
.ToList();
return finished;
@ -224,7 +249,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
return await GetNewTopStats(start, count, serverId);
}
await using var context = _contextFactory.CreateContext(enableTracking: false);
// setup the query for the clients within the given rating range
var iqClientRatings = (from rating in context.Set<EFRating>()
@ -267,7 +292,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.Select(grp => new
{
grp.Key,
Ratings = grp.Select(r => new {r.Performance, r.Ranking, r.When})
Ratings = grp.Select(r => new { r.Performance, r.Ranking, r.When })
});
var iqStatsInfo = (from stat in context.Set<EFClientStatistics>()
@ -281,7 +306,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
ClientId = s.Key,
Kills = s.Sum(c => c.Kills),
Deaths = s.Sum(c => c.Deaths),
KDR = s.Sum(c => (c.Kills / (double) (c.Deaths == 0 ? 1 : c.Deaths)) * c.TimePlayed) /
KDR = s.Sum(c => (c.Kills / (double)(c.Deaths == 0 ? 1 : c.Deaths)) * c.TimePlayed) /
s.Sum(c => c.TimePlayed),
TotalTimePlayed = s.Sum(c => c.TimePlayed),
});
@ -379,7 +404,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
Port = sv.Port,
EndPoint = sv.ToString(),
ServerId = serverId,
GameName = (Reference.Game?) sv.GameName,
GameName = (Reference.Game?)sv.GameName,
HostName = sv.Hostname
};
@ -389,9 +414,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
// we want to set the gamename up if it's never been set, or it changed
else if (!server.GameName.HasValue || server.GameName.Value != (Reference.Game) sv.GameName)
else if (!server.GameName.HasValue || server.GameName.Value != (Reference.Game)sv.GameName)
{
server.GameName = (Reference.Game) sv.GameName;
server.GameName = (Reference.Game)sv.GameName;
ctx.Entry(server).Property(_prop => _prop.GameName).IsModified = true;
ctx.SaveChanges();
}
@ -482,7 +507,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
Active = true,
HitCount = 0,
Location = (int) hl
Location = (int)hl
}).ToList()
};
@ -502,7 +527,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
Active = true,
HitCount = 0,
Location = (int) hl
Location = (int)hl
})
.ToList();
@ -534,9 +559,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
catch (DbUpdateException updateException) when (
updateException.InnerException is PostgresException {SqlState: "23503"}
|| updateException.InnerException is SqliteException {SqliteErrorCode: 787}
|| updateException.InnerException is MySqlException {SqlState: "23503"})
updateException.InnerException is PostgresException { SqlState: "23503" }
|| updateException.InnerException is SqliteException { SqliteErrorCode: 787 }
|| updateException.InnerException is MySqlException { SqlState: "23503" })
{
_log.LogWarning("Trying to add {Client} to stats before they have been added to the database",
pl.ToString());
@ -657,9 +682,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
ServerId = serverId,
DeathOrigin = vDeathOrigin,
KillOrigin = vKillOrigin,
DeathType = (int) ParseEnum<IW4Info.MeansOfDeath>.Get(type, typeof(IW4Info.MeansOfDeath)),
DeathType = (int)ParseEnum<IW4Info.MeansOfDeath>.Get(type, typeof(IW4Info.MeansOfDeath)),
Damage = int.Parse(damage),
HitLoc = (int) ParseEnum<IW4Info.HitLocation>.Get(hitLoc, typeof(IW4Info.HitLocation)),
HitLoc = (int)ParseEnum<IW4Info.HitLocation>.Get(hitLoc, typeof(IW4Info.HitLocation)),
WeaponReference = weapon,
ViewAngles = vViewAngles,
TimeOffset = long.Parse(offset),
@ -673,21 +698,21 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
AnglesList = snapshotAngles,
IsAlive = isAlive == "1",
TimeSinceLastAttack = long.Parse(lastAttackTime),
GameName = (int) attacker.CurrentServer.GameName
GameName = (int)attacker.CurrentServer.GameName
};
}
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);
return;
}
hit.SetAdditionalProperty("HitLocationReference", hitLoc);
if (hit.HitLoc == (int) IW4Info.HitLocation.shield)
if (hit.HitLoc == (int)IW4Info.HitLocation.shield)
{
// we don't care about shield hits
return;
@ -706,9 +731,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
await waiter.WaitAsync(Utilities.DefaultCommandTimeout, Plugin.ServerManager.CancellationToken);
// increment their hit count
if (hit.DeathType == (int) IW4Info.MeansOfDeath.MOD_PISTOL_BULLET ||
hit.DeathType == (int) IW4Info.MeansOfDeath.MOD_RIFLE_BULLET ||
hit.DeathType == (int) IW4Info.MeansOfDeath.MOD_HEAD_SHOT)
if (hit.DeathType == (int)IW4Info.MeansOfDeath.MOD_PISTOL_BULLET ||
hit.DeathType == (int)IW4Info.MeansOfDeath.MOD_RIFLE_BULLET ||
hit.DeathType == (int)IW4Info.MeansOfDeath.MOD_HEAD_SHOT)
{
clientStats.HitLocations.First(hl => hl.Location == hit.HitLoc).HitCount += 1;
}
@ -851,7 +876,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
catch (KeyNotFoundException)
{
}
try
{
if (!gameDetectionTypes[server.GameName].Contains(detectionType))
@ -883,7 +908,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
new EFPenalty()
{
AutomatedOffense = penalty.Type == Detection.DetectionType.Bone
? $"{penalty.Type}-{(int) penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}"
? $"{penalty.Type}-{(int)penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}"
: $"{penalty.Type}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}",
}
};
@ -900,7 +925,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
string flagReason = penalty.Type == Cheat.Detection.DetectionType.Bone
? $"{penalty.Type}-{(int) penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}"
? $"{penalty.Type}-{(int)penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}"
: $"{penalty.Type}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}";
penaltyClient.AdministeredPenalties = new List<EFPenalty>()
@ -939,19 +964,19 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
// update the total stats
_servers[serverId].ServerStatistics.TotalKills += 1;
if (attackerStats == null)
{
_log.LogWarning("Stats for {Client} are not yet initialized", attacker.ToString());
return;
}
if (victimStats == null)
{
_log.LogWarning("Stats for {Client} are not yet initialized", victim.ToString());
return;
}
// this happens when the round has changed
if (attackerStats.SessionScore == 0)
{
@ -964,10 +989,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
var estimatedAttackerScore = attacker.CurrentServer.GameName != Server.Game.CSGO
? attacker.Score
? attacker.Score
: attackerStats.SessionKills * 50;
var estimatedVictimScore = attacker.CurrentServer.GameName != Server.Game.CSGO
? victim.Score
var estimatedVictimScore = attacker.CurrentServer.GameName != Server.Game.CSGO
? victim.Score
: victimStats.SessionKills * 50;
attackerStats.SessionScore = estimatedAttackerScore;
@ -1055,7 +1080,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
/// <returns></returns>
public async Task UpdateStatHistory(EFClient client, EFClientStatistics clientStats)
{
int currentSessionTime = (int) (DateTime.UtcNow - client.LastConnection).TotalSeconds;
int currentSessionTime = (int)(DateTime.UtcNow - client.LastConnection).TotalSeconds;
// don't update their stat history if they haven't played long
if (currentSessionTime < 60)
@ -1228,7 +1253,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
await using var context = _contextFactory.CreateContext();
var minPlayTime = _config.TopPlayersMinPlayTime;
var performances = await context.Set<EFClientStatistics>()
.AsNoTracking()
.Where(stat => stat.ClientId == clientId)
@ -1236,7 +1261,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.Where(stats => stats.UpdatedAt >= Extensions.FifteenDaysAgo())
.Where(stats => stats.TimePlayed >= minPlayTime)
.ToListAsync();
if (clientStats.TimePlayed >= minPlayTime)
{
clientStats.ZScore = await _serverDistributionCalculator.GetZScoreForServer(serverId,
@ -1267,8 +1292,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
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>()
.Where(stat => stat.ClientId != clientId)
.Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(minPlayTime))
@ -1287,7 +1313,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
clientStats.Client?.ToString(), aggregateZScore);
return;
}
var aggregateRankingSnapshot = new EFClientRankingHistory
{
ClientId = clientId,
@ -1310,7 +1336,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId)
.CountAsync();
var mostRecent = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId)
@ -1322,14 +1348,20 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
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>()
.Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId)
.OrderBy(r => r.CreatedDateTime)
.FirstOrDefaultAsync();
context.Remove(lastRating);
if (lastRating is not null)
{
context.Remove(lastRating);
}
}
}
@ -1338,7 +1370,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
/// </summary>
/// <param name="attackerStats">Stats of the attacker</param>
/// <param name="victimStats">Stats of the victim</param>
public void CalculateKill(EFClientStatistics attackerStats, EFClientStatistics victimStats,
public void CalculateKill(EFClientStatistics attackerStats, EFClientStatistics victimStats,
EFClient attacker, EFClient victim)
{
bool suicide = attackerStats.ClientId == victimStats.ClientId;
@ -1364,7 +1396,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
// calculate elo
var attackerEloDifference = Math.Log(Math.Max(1, victimStats.EloRating)) -
Math.Log(Math.Max(1, attackerStats.EloRating));
Math.Log(Math.Max(1, attackerStats.EloRating));
var winPercentage = 1.0 / (1 + Math.Pow(10, attackerEloDifference / Math.E));
attackerStats.EloRating += 6.0 * (1 - winPercentage);
@ -1374,8 +1406,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
victimStats.EloRating = Math.Max(0, Math.Round(victimStats.EloRating, 2));
// update after calculation
attackerStats.TimePlayed += (int) (DateTime.UtcNow - attackerStats.LastActive).TotalSeconds;
victimStats.TimePlayed += (int) (DateTime.UtcNow - victimStats.LastActive).TotalSeconds;
attackerStats.TimePlayed += (int)(DateTime.UtcNow - attackerStats.LastActive).TotalSeconds;
victimStats.TimePlayed += (int)(DateTime.UtcNow - victimStats.LastActive).TotalSeconds;
attackerStats.LastActive = DateTime.UtcNow;
victimStats.LastActive = DateTime.UtcNow;
}
@ -1413,11 +1445,11 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
var killSpm = scoreDifference / timeSinceLastCalc;
var spmMultiplier = 2.934 *
Math.Pow(
_servers[clientStats.ServerId]
.TeamCount((IW4Info.Team) clientStats.Team == IW4Info.Team.Allies
? IW4Info.Team.Axis
: IW4Info.Team.Allies), -0.454);
Math.Pow(
_servers[clientStats.ServerId]
.TeamCount((IW4Info.Team)clientStats.Team == IW4Info.Team.Allies
? IW4Info.Team.Axis
: IW4Info.Team.Allies), -0.454);
killSpm *= Math.Max(1, spmMultiplier);
// update this for ac tracking
@ -1434,8 +1466,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
// calculate the weight of the new play time against last 10 hours of gameplay
int totalPlayTime = (clientStats.TimePlayed == 0)
? (int) (DateTime.UtcNow - clientStats.LastActive).TotalSeconds
: clientStats.TimePlayed + (int) (DateTime.UtcNow - clientStats.LastActive).TotalSeconds;
? (int)(DateTime.UtcNow - clientStats.LastActive).TotalSeconds
: clientStats.TimePlayed + (int)(DateTime.UtcNow - clientStats.LastActive).TotalSeconds;
double SPMAgainstPlayWeight = timeSinceLastCalc / Math.Min(600, (totalPlayTime / 60.0));
@ -1455,7 +1487,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
if (double.IsNaN(clientStats.SPM) || double.IsNaN(clientStats.Skill))
{
_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.Skill = 0;
}
@ -1496,11 +1531,11 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
public void ResetKillstreaks(Server sv)
{
foreach (var session in sv.GetClientsAsList()
.Select(_client => new
{
stat = _client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY),
detection = _client.GetAdditionalProperty<Detection>(CLIENT_DETECTIONS_KEY)
}))
.Select(_client => new
{
stat = _client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY),
detection = _client.GetAdditionalProperty<Detection>(CLIENT_DETECTIONS_KEY)
}))
{
session.stat?.StartNewSession();
session.detection?.OnMapChange();
@ -1562,8 +1597,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
await ctx.SaveChangesAsync();
foreach (var stats in sv.GetClientsAsList()
.Select(_client => _client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY))
.Where(_stats => _stats != null))
.Select(_client => _client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY))
.Where(_stats => _stats != null))
{
await SaveClientStats(stats);
}

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 CurrentServerName { get; set; }
public IGeoLocationResult GeoLocationInfo { get; set; }
public ClientNoteMetaResponse NoteMeta { get; set; }
}
}

View File

@ -938,6 +938,14 @@ namespace SharedLibraryCore.Services
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
}
}

View File

@ -192,17 +192,7 @@ namespace SharedLibraryCore.Services
.Where(FilterById);
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)
{
await using var context = _contextFactory.CreateContext(false);

View File

@ -2,17 +2,20 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Data.Models;
using Data.Models.Client;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SharedLibraryCore;
using SharedLibraryCore.Commands;
using SharedLibraryCore.Configuration;
using SharedLibraryCore.Dtos;
using SharedLibraryCore.Dtos.Meta.Responses;
using SharedLibraryCore.Helpers;
using SharedLibraryCore.Interfaces;
using WebfrontCore.Permissions;
using WebfrontCore.ViewModels;
namespace WebfrontCore.Controllers
@ -20,6 +23,7 @@ namespace WebfrontCore.Controllers
public class ActionController : BaseController
{
private readonly ApplicationConfiguration _appConfig;
private readonly IMetaServiceV2 _metaService;
private readonly string _banCommandName;
private readonly string _tempbanCommandName;
private readonly string _unbanCommandName;
@ -29,11 +33,14 @@ namespace WebfrontCore.Controllers
private readonly string _flagCommandName;
private readonly string _unflagCommandName;
private readonly string _setLevelCommandName;
private readonly string _setClientTagCommandName;
private readonly string _addClientNoteCommandName;
public ActionController(IManager manager, IEnumerable<IManagerCommand> registeredCommands,
ApplicationConfiguration appConfig) : base(manager)
ApplicationConfiguration appConfig, IMetaServiceV2 metaService) : base(manager)
{
_appConfig = appConfig;
_metaService = metaService;
foreach (var cmd in registeredCommands)
{
@ -69,6 +76,12 @@ namespace WebfrontCore.Controllers
case "OfflineMessageCommand":
_offlineMessageCommandName = cmd.Name;
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
.Concat(_appConfig.GlobalRules)
.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.Tasks;
using Data.Models;
using SharedLibraryCore.Services;
using Stats.Config;
using WebfrontCore.Permissions;
using WebfrontCore.ViewComponents;
@ -23,13 +24,15 @@ namespace WebfrontCore.Controllers
private readonly IMetaServiceV2 _metaService;
private readonly StatsConfiguration _config;
private readonly IGeoLocationService _geoLocationService;
private readonly ClientService _clientService;
public ClientController(IManager manager, IMetaServiceV2 metaService, StatsConfiguration config,
IGeoLocationService geoLocationService) : base(manager)
IGeoLocationService geoLocationService, ClientService clientService) : base(manager)
{
_metaService = metaService;
_config = config;
_geoLocationService = geoLocationService;
_clientService = clientService;
}
[Obsolete]
@ -53,18 +56,25 @@ namespace WebfrontCore.Controllers
{
_metaService.GetPersistentMetaByLookup(EFMeta.ClientTagV2, EFMeta.ClientTagNameV2, client.ClientId,
token),
_metaService.GetPersistentMeta("GravatarEmail", client.ClientId, token)
_metaService.GetPersistentMeta("GravatarEmail", client.ClientId, token),
};
var persistentMeta = await Task.WhenAll(persistentMetaTask);
var tag = persistentMeta[0];
var gravatar = persistentMeta[1];
var note = await _metaService.GetPersistentMetaValue<ClientNoteMetaResponse>("ClientNotes", client.ClientId,
token);
if (tag?.Value != null)
{
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
// (ie they haven't reconnected with the infringing player identifier)
// we want to show them as banned as to not confuse people.
@ -123,7 +133,8 @@ namespace WebfrontCore.Controllers
: ingameClient.CurrentServer.IP,
ingameClient.CurrentServer.Port),
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

View File

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

View File

@ -25,11 +25,20 @@
@if (inputType == "select")
{
<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">
<color-code value="@item.Value"></color-code>
</option>
if (key.StartsWith("!selected!"))
{
<option value="@key.Replace("!selected!", "")" selected>
<color-code value="@item"></color-code>
</option>
}
else
{
<option value="@key">
<color-code value="@item"></color-code>
</option>
}
}
</select>
}
@ -42,6 +51,11 @@
</label>
</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
{

View File

@ -30,7 +30,7 @@
</td>
<td>
@info.Data
<td>
<td class="text-force-break font-weight-light">
@info.NewValue
</td>
<td class="text-right">
@ -40,32 +40,36 @@
<!-- mobile -->
<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_ADMIN"]</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_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 class="w-three-quarter d-flex flex-column">
<div class="mt-5 mb-5">@info.Action</div>
<a asp-controller="Client" asp-action="Profile" asp-route-id="@info.OriginId" class="link-inverse">
<color-code value="@info.OriginName"></color-code>
</a>
<div class="mt-5 mb-5">
<a asp-controller="Client" asp-action="Profile" asp-route-id="@info.OriginId" class="link-inverse">
<color-code value="@info.OriginName"></color-code>
</a>
</div>
@if (info.TargetId != null)
{
<a asp-controller="Client" asp-action="Profile" asp-route-id="@info.TargetId" class="mt-5 mb-5">
<color-code value="@info.TargetName"></color-code>
</a>
<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>
</a>
</div>
}
else
{
<div class="mt-5 mb-5">&ndash;</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">@info.When</div>
<div class="mt-5 mb-5 text-force-break">@info.NewValue</div>
<div class="mt-5 mb-5 text-muted">@info.When.ToStandardFormat()</div>
</td>
</tr>
}

View File

@ -173,6 +173,27 @@
</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">
<!-- country flag -->
<div id="ipGeoDropdown" class="dropdown with-arrow align-self-center">
@ -279,9 +300,30 @@
EntityId = Model.ClientId
});
}
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
{
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 performanceHistory = Model.Ratings
.OrderBy(rating => 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()
@ -285,7 +286,7 @@
<!-- history graph -->
@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>
</div>
}

View File

@ -1,5 +1,4 @@
@using IW4MAdmin.Plugins.Stats
@using System.Text.Json.Serialization
@using System.Text.Json
@model List<IW4MAdmin.Plugins.Stats.Web.Dtos.TopStatsInfo>
@{
@ -86,7 +85,7 @@
</div>
</div>
<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 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"/>

View File

@ -41,14 +41,14 @@
<!-- mobile -->
<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_TYPE"]</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>
</td>
<td class="flex-fill">
<td class=" d-flex flex-column w-three-quarter">
<div class="mt-5 mb-5">
<a asp-controller="Client" asp-action="Profile" asp-route-id="@Model.OffenderId" >
<color-code value="@Model.OffenderName"></color-code>
@ -57,13 +57,13 @@
<div class="mt-5 mb-5 penalties-color-@Model.PenaltyTypeText.ToLower()">
@ViewBag.Localization[$"WEBFRONT_PENALTY_{Model.PenaltyType.ToString().ToUpper()}"]
</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>
</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)">
<color-code value="@Model.PunisherName"></color-code>
</a>
<div class="mt-5 mb-5">
<div class="mt-5 mb-5 text-muted">
@if (Model.Expired)
{
<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}";
<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">
<i class="oi oi-circle-x font-size-12 @levelColorClass"></i>
</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"]">
<i class="oi oi-play-circle ml-5 mr-5"></i>
</a>
<has-permission entity="AdminMenu" required-permission="Update">
<has-permission entity="AdminMenu" required-permission="Write">
<!-- send message button -->
<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>

View File

@ -12,7 +12,7 @@
"outputFileName": "wwwroot/js/global.min.js",
"inputFiles": [
"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/chart.js/dist/Chart.bundle.min.js",
"wwwroot/lib/halfmoon/js/halfmoon.min.js",

View File

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

View File

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

View File

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