mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2025-04-10 20:15:44 +02:00
Check Agent instance and memory limits on instance launch instead of instance creation
This commit is contained in:
parent
e40459a039
commit
dca52bb6ad
Agent/Phantom.Agent.Services
Common
Phantom.Common.Data/Replies
Phantom.Common.Messages
Server
Phantom.Server.Services
Agents
Instances
Rpc
Phantom.Server.Web
Phantom.Server
Utils/Phantom.Utils.Collections
@ -37,6 +37,10 @@ sealed class Instance : IDisposable {
|
||||
private IInstanceState currentState;
|
||||
private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
|
||||
|
||||
public bool IsRunning => currentState is not InstanceNotRunningState;
|
||||
|
||||
public event EventHandler? IsRunningChanged;
|
||||
|
||||
private Instance(InstanceConfiguration configuration, BaseLauncher launcher, LaunchServices launchServices, PortManager portManager) {
|
||||
this.shortName = GetLoggerName(configuration.InstanceGuid);
|
||||
this.logger = PhantomLogger.Create<Instance>(shortName);
|
||||
@ -65,8 +69,13 @@ sealed class Instance : IDisposable {
|
||||
|
||||
logger.Verbose("Transitioning instance state to: {NewState}", newState.GetType().Name);
|
||||
|
||||
var wasRunning = IsRunning;
|
||||
currentState = newState;
|
||||
currentState.Initialize();
|
||||
|
||||
if (IsRunning != wasRunning) {
|
||||
IsRunningChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private T TransitionStateAndReturn<T>((IInstanceState State, T Result) newStateAndResult) {
|
||||
@ -155,6 +164,13 @@ sealed class Instance : IDisposable {
|
||||
instance.stateTransitioningActionSemaphore.Wait(CancellationToken.None);
|
||||
try {
|
||||
var (state, status) = newStateAndStatus();
|
||||
|
||||
if (!instance.IsRunning) {
|
||||
// Only InstanceSessionManager is allowed to transition an instance out of a non-running state.
|
||||
instance.logger.Verbose("Cancelled state transition to {State} because instance is not running.", state.GetType().Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state is not InstanceNotRunningState && shutdownCancellationToken.IsCancellationRequested) {
|
||||
instance.logger.Verbose("Cancelled state transition to {State} due to Agent shutdown.", state.GetType().Name);
|
||||
return;
|
||||
|
@ -1,16 +1,19 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Phantom.Agent.Minecraft.Instance;
|
||||
using Phantom.Agent.Minecraft.Java;
|
||||
using Phantom.Agent.Minecraft.Launcher;
|
||||
using Phantom.Agent.Minecraft.Launcher.Types;
|
||||
using Phantom.Agent.Minecraft.Properties;
|
||||
using Phantom.Agent.Minecraft.Server;
|
||||
using Phantom.Agent.Rpc;
|
||||
using Phantom.Common.Data;
|
||||
using Phantom.Common.Data.Agent;
|
||||
using Phantom.Common.Data.Instance;
|
||||
using Phantom.Common.Data.Minecraft;
|
||||
using Phantom.Common.Data.Replies;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Messages.ToServer;
|
||||
using Phantom.Utils.IO;
|
||||
using Phantom.Utils.Runtime;
|
||||
using Serilog;
|
||||
@ -44,7 +47,6 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) {
|
||||
try {
|
||||
await semaphore.WaitAsync(shutdownCancellationToken);
|
||||
|
||||
try {
|
||||
return await func();
|
||||
} finally {
|
||||
@ -70,26 +72,15 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(InstanceConfiguration configuration) {
|
||||
return await AcquireSemaphoreAndRun(async () => {
|
||||
var instanceGuid = configuration.InstanceGuid;
|
||||
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
|
||||
Directories.Create(instanceFolder, Chmod.URWX_GRX);
|
||||
|
||||
var otherInstances = instances.Values.Where(inst => inst.Configuration.InstanceGuid != instanceGuid).ToArray();
|
||||
if (otherInstances.Length + 1 > agentInfo.MaxInstances) {
|
||||
return InstanceActionResult.Concrete(ConfigureInstanceResult.InstanceLimitExceeded);
|
||||
}
|
||||
|
||||
var availableMemory = agentInfo.MaxMemory - otherInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
|
||||
if (availableMemory < configuration.MemoryAllocation) {
|
||||
return InstanceActionResult.Concrete(ConfigureInstanceResult.MemoryLimitExceeded);
|
||||
}
|
||||
|
||||
var heapMegabytes = configuration.MemoryAllocation.InMegabytes;
|
||||
var jvmProperties = new JvmProperties(
|
||||
InitialHeapMegabytes: heapMegabytes / 2,
|
||||
MaximumHeapMegabytes: heapMegabytes
|
||||
);
|
||||
|
||||
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
|
||||
Directories.Create(instanceFolder, Chmod.URWX_GRX);
|
||||
|
||||
var properties = new InstanceProperties(
|
||||
configuration.JavaRuntimeGuid,
|
||||
jvmProperties,
|
||||
@ -107,19 +98,58 @@ sealed class InstanceSessionManager : IDisposable {
|
||||
}
|
||||
else {
|
||||
instances[instanceGuid] = instance = await Instance.Create(configuration, launcher, launchServices, portManager);
|
||||
instance.IsRunningChanged += OnInstanceIsRunningChanged;
|
||||
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
|
||||
}
|
||||
|
||||
if (configuration.LaunchAutomatically) {
|
||||
await instance.Launch(shutdownCancellationToken);
|
||||
await LaunchInternal(instance);
|
||||
}
|
||||
|
||||
return InstanceActionResult.Concrete(ConfigureInstanceResult.Success);
|
||||
});
|
||||
}
|
||||
|
||||
private ImmutableArray<Instance> GetRunningInstancesInternal() {
|
||||
return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray();
|
||||
}
|
||||
|
||||
private void OnInstanceIsRunningChanged(object? sender, EventArgs e) {
|
||||
launchServices.TaskManager.Run("Handle instance running state changed event", RefreshAgentStatus);
|
||||
}
|
||||
|
||||
public async Task RefreshAgentStatus() {
|
||||
try {
|
||||
await semaphore.WaitAsync(shutdownCancellationToken);
|
||||
try {
|
||||
var runningInstances = GetRunningInstancesInternal();
|
||||
var runningInstanceCount = runningInstances.Length;
|
||||
var runningInstanceMemory = runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
|
||||
await ServerMessaging.Send(new ReportAgentStatusMessage(runningInstanceCount, runningInstanceMemory));
|
||||
} finally {
|
||||
semaphore.Release();
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
public Task<InstanceActionResult<LaunchInstanceResult>> Launch(Guid instanceGuid) {
|
||||
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Launch(shutdownCancellationToken));
|
||||
return AcquireSemaphoreAndRunWithInstance(instanceGuid, LaunchInternal);
|
||||
}
|
||||
|
||||
private async Task<LaunchInstanceResult> LaunchInternal(Instance instance) {
|
||||
var runningInstances = GetRunningInstancesInternal();
|
||||
if (runningInstances.Length + 1 > agentInfo.MaxInstances) {
|
||||
return LaunchInstanceResult.InstanceLimitExceeded;
|
||||
}
|
||||
|
||||
var availableMemory = agentInfo.MaxMemory - runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
|
||||
if (availableMemory < instance.Configuration.MemoryAllocation) {
|
||||
return LaunchInstanceResult.MemoryLimitExceeded;
|
||||
}
|
||||
|
||||
return await instance.Launch(shutdownCancellationToken);
|
||||
}
|
||||
|
||||
public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
|
||||
|
@ -37,6 +37,8 @@ public sealed class MessageListener : IMessageToAgentListener {
|
||||
}
|
||||
|
||||
await ServerMessaging.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
|
||||
await agent.InstanceSessionManager.RefreshAgentStatus();
|
||||
|
||||
return NoReply.Instance;
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,5 @@
|
||||
namespace Phantom.Common.Data.Replies;
|
||||
|
||||
public enum ConfigureInstanceResult : byte {
|
||||
Success,
|
||||
InstanceLimitExceeded,
|
||||
MemoryLimitExceeded
|
||||
Success
|
||||
}
|
||||
|
@ -5,7 +5,9 @@ public enum LaunchInstanceResult : byte {
|
||||
LaunchInitiated,
|
||||
InstanceAlreadyLaunching,
|
||||
InstanceAlreadyRunning,
|
||||
InstanceIsStopping
|
||||
InstanceIsStopping,
|
||||
InstanceLimitExceeded,
|
||||
MemoryLimitExceeded
|
||||
}
|
||||
|
||||
public static class LaunchInstanceResultExtensions {
|
||||
@ -15,6 +17,8 @@ public static class LaunchInstanceResultExtensions {
|
||||
LaunchInstanceResult.InstanceAlreadyLaunching => "Instance is already launching.",
|
||||
LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.",
|
||||
LaunchInstanceResult.InstanceIsStopping => "Instance is stopping.",
|
||||
LaunchInstanceResult.InstanceLimitExceeded => "Agent does not have any more available instances.",
|
||||
LaunchInstanceResult.MemoryLimitExceeded => "Agent does not have enough available memory.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ public interface IMessageToServerListener {
|
||||
Task<NoReply> HandleUnregisterAgent(UnregisterAgentMessage message);
|
||||
Task<NoReply> HandleAgentIsAlive(AgentIsAliveMessage message);
|
||||
Task<NoReply> HandleAdvertiseJavaRuntimes(AdvertiseJavaRuntimesMessage message);
|
||||
Task<NoReply> HandleReportAgentStatus(ReportAgentStatusMessage message);
|
||||
Task<NoReply> HandleReportInstanceStatus(ReportInstanceStatusMessage message);
|
||||
Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message);
|
||||
Task<NoReply> HandleReply(ReplyMessage message);
|
||||
|
@ -26,6 +26,7 @@ public static class MessageRegistries {
|
||||
ToServer.Add<AdvertiseJavaRuntimesMessage>(3);
|
||||
ToServer.Add<ReportInstanceStatusMessage>(4);
|
||||
ToServer.Add<InstanceOutputMessage>(5);
|
||||
ToServer.Add<ReportAgentStatusMessage>(6);
|
||||
ToServer.Add<ReplyMessage>(127);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
using MemoryPack;
|
||||
using Phantom.Common.Data;
|
||||
using Phantom.Utils.Rpc.Message;
|
||||
|
||||
namespace Phantom.Common.Messages.ToServer;
|
||||
|
||||
[MemoryPackable]
|
||||
public partial record ReportAgentStatusMessage(
|
||||
[property: MemoryPackOrder(0)] int RunningInstanceCount,
|
||||
[property: MemoryPackOrder(1)] RamAllocationUnits RunningInstanceMemory
|
||||
) : IMessageToServer {
|
||||
public Task<NoReply> Accept(IMessageToServerListener listener) {
|
||||
return listener.HandleReportAgentStatus(this);
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ public sealed record Agent(
|
||||
RamAllocationUnits MaxMemory,
|
||||
AllowedPorts? AllowedServerPorts = null,
|
||||
AllowedPorts? AllowedRconPorts = null,
|
||||
AgentStats? Stats = null,
|
||||
DateTimeOffset? LastPing = null
|
||||
) {
|
||||
internal AgentConnection? Connection { get; init; }
|
||||
@ -19,6 +20,8 @@ public sealed record Agent(
|
||||
public bool IsOnline { get; internal init; }
|
||||
public bool IsOffline => !IsOnline;
|
||||
|
||||
public RamAllocationUnits? AvailableMemory => MaxMemory - Stats?.RunningInstanceMemory;
|
||||
|
||||
internal Agent(AgentInfo info) : this(info.Guid, info.Name, info.ProtocolVersion, info.BuildVersion, info.MaxInstances, info.MaxMemory, info.AllowedServerPorts, info.AllowedRconPorts) {}
|
||||
|
||||
internal Agent AsDisconnected() => this with {
|
||||
@ -27,6 +30,7 @@ public sealed record Agent(
|
||||
|
||||
internal Agent AsOffline() => this with {
|
||||
Connection = null,
|
||||
Stats = null,
|
||||
IsOnline = false
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using Phantom.Common.Data;
|
||||
using Phantom.Common.Data.Agent;
|
||||
using Phantom.Common.Data.Replies;
|
||||
using Phantom.Common.Logging;
|
||||
@ -47,6 +48,10 @@ public sealed class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
public ImmutableDictionary<Guid, Agent> GetAgents() {
|
||||
return agents.ByGuid.ToImmutable();
|
||||
}
|
||||
|
||||
internal async Task<bool> RegisterAgent(AgentAuthToken authToken, AgentInfo agentInfo, InstanceManager instanceManager, RpcClientConnection connection) {
|
||||
if (!this.authToken.FixedTimeEquals(authToken)) {
|
||||
await connection.Send(new RegisterAgentFailureMessage(RegisterAgentFailure.InvalidToken));
|
||||
@ -90,7 +95,7 @@ public sealed class AgentManager {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal Agent? GetAgent(Guid guid) {
|
||||
return agents.ByGuid.TryGetValue(guid, out var agent) ? agent : null;
|
||||
}
|
||||
@ -99,6 +104,10 @@ public sealed class AgentManager {
|
||||
agents.ByGuid.TryReplace(agentGuid, static agent => agent with { LastPing = DateTimeOffset.Now });
|
||||
}
|
||||
|
||||
internal void SetAgentStats(Guid agentGuid, int runningInstanceCount, RamAllocationUnits runningInstanceMemory) {
|
||||
agents.ByGuid.TryReplace(agentGuid, agent => agent with { Stats = new AgentStats(runningInstanceCount, runningInstanceMemory) });
|
||||
}
|
||||
|
||||
private async Task RefreshAgentStatus() {
|
||||
static Agent MarkAgentAsOffline(Agent agent) {
|
||||
Logger.Warning("Lost connection to agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);
|
||||
|
@ -3,9 +3,6 @@
|
||||
namespace Phantom.Server.Services.Agents;
|
||||
|
||||
public sealed record AgentStats(
|
||||
Agent Agent,
|
||||
int UsedInstances,
|
||||
RamAllocationUnits UsedMemory
|
||||
) {
|
||||
public RamAllocationUnits AvailableMemory => Agent.MaxMemory - UsedMemory;
|
||||
}
|
||||
int RunningInstanceCount,
|
||||
RamAllocationUnits RunningInstanceMemory
|
||||
);
|
||||
|
@ -1,75 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Phantom.Common.Data;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Server.Services.Instances;
|
||||
using Phantom.Utils.Events;
|
||||
using ILogger = Serilog.ILogger;
|
||||
|
||||
namespace Phantom.Server.Services.Agents;
|
||||
|
||||
public sealed class AgentStatsManager {
|
||||
private readonly ObservableAgentStats agentStats = new (PhantomLogger.Create<AgentManager, ObservableAgentStats>());
|
||||
|
||||
public EventSubscribers<ImmutableArray<AgentStats>> AgentStatsChanged => agentStats.Subs;
|
||||
|
||||
public AgentStatsManager(AgentManager agentManager, InstanceManager instanceManager) {
|
||||
agentManager.AgentsChanged.Subscribe(this, agentStats.UpdateAgents);
|
||||
instanceManager.InstancesChanged.Subscribe(this, agentStats.UpdateInstances);
|
||||
}
|
||||
|
||||
public ImmutableDictionary<Guid, AgentStats> GetOnlineAgentStats() {
|
||||
return agentStats.GetOnlineAgentStats();
|
||||
}
|
||||
|
||||
public AgentStats? GetAgentStats(Guid agentGuid) {
|
||||
return agentStats.GetAgentStats(agentGuid);
|
||||
}
|
||||
|
||||
private sealed class ObservableAgentStats : ObservableState<ImmutableArray<AgentStats>> {
|
||||
private ImmutableDictionary<Guid, Agent> agents = ImmutableDictionary<Guid, Agent>.Empty;
|
||||
private ImmutableDictionary<Guid, ImmutableArray<Instance>> instancesByAgentGuid = ImmutableDictionary<Guid, ImmutableArray<Instance>>.Empty;
|
||||
|
||||
public ObservableAgentStats(ILogger logger) : base(logger) {}
|
||||
|
||||
public void UpdateAgents(ImmutableArray<Agent> newAgents) {
|
||||
agents = newAgents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent);
|
||||
Update();
|
||||
}
|
||||
|
||||
public void UpdateInstances(ImmutableDictionary<Guid, Instance> newInstances) {
|
||||
instancesByAgentGuid = newInstances.Values.GroupBy(static instance => instance.Configuration.AgentGuid, static (agentGuid, instances) => KeyValuePair.Create(agentGuid, instances.ToImmutableArray())).ToImmutableDictionary();
|
||||
Update();
|
||||
}
|
||||
|
||||
public AgentStats? GetAgentStats(Guid agentGuid) {
|
||||
return agents.TryGetValue(agentGuid, out var agent) ? ComputeAgentStats(instancesByAgentGuid, agent) : null;
|
||||
}
|
||||
|
||||
public ImmutableDictionary<Guid, AgentStats> GetOnlineAgentStats() {
|
||||
return agents.Values
|
||||
.Where(static agent => agent.IsOnline)
|
||||
.Select(agent => ComputeAgentStats(instancesByAgentGuid, agent))
|
||||
.ToImmutableDictionary(static stats => stats.Agent.Guid);
|
||||
}
|
||||
|
||||
protected override ImmutableArray<AgentStats> GetData() {
|
||||
return agents.Values
|
||||
.Select(agent => ComputeAgentStats(instancesByAgentGuid, agent))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static AgentStats ComputeAgentStats(ImmutableDictionary<Guid, ImmutableArray<Instance>> instancesByAgentGuid, Agent agent) {
|
||||
int usedInstances = 0;
|
||||
var usedMemory = RamAllocationUnits.Zero;
|
||||
|
||||
if (instancesByAgentGuid.TryGetValue(agent.Guid, out var instances)) {
|
||||
foreach (var instance in instances) {
|
||||
usedInstances += 1;
|
||||
usedMemory += instance.Configuration.MemoryAllocation;
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentStats(agent, usedInstances, usedMemory);
|
||||
}
|
||||
}
|
||||
}
|
@ -6,9 +6,7 @@ public enum AddInstanceResult : byte {
|
||||
InstanceAlreadyExists,
|
||||
InstanceNameMustNotBeEmpty,
|
||||
InstanceMemoryMustNotBeZero,
|
||||
AgentNotFound,
|
||||
AgentInstanceLimitExceeded,
|
||||
AgentMemoryLimitExceeded
|
||||
AgentNotFound
|
||||
}
|
||||
|
||||
public static class AddInstanceResultExtensions {
|
||||
@ -18,8 +16,6 @@ public static class AddInstanceResultExtensions {
|
||||
AddInstanceResult.InstanceNameMustNotBeEmpty => "Instance name must not be empty.",
|
||||
AddInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.",
|
||||
AddInstanceResult.AgentNotFound => "Agent not found.",
|
||||
AddInstanceResult.AgentInstanceLimitExceeded => "Agent instance limit exceeded.",
|
||||
AddInstanceResult.AgentMemoryLimitExceeded => "Agent memory limit exceeded.",
|
||||
_ => "Unknown error."
|
||||
};
|
||||
}
|
||||
|
@ -68,8 +68,13 @@ public sealed class InstanceManager {
|
||||
|
||||
var agentName = agent.Name;
|
||||
|
||||
var reply = (await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, new ConfigureInstanceMessage(configuration), TimeSpan.FromSeconds(10))).DidNotReplyIfNull();
|
||||
if (reply.Is(ConfigureInstanceResult.Success)) {
|
||||
var reply = await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, new ConfigureInstanceMessage(configuration), TimeSpan.FromSeconds(10));
|
||||
var result = reply.DidNotReplyIfNull().Map(static result => result switch {
|
||||
ConfigureInstanceResult.Success => AddInstanceResult.Success,
|
||||
_ => AddInstanceResult.UnknownError
|
||||
});
|
||||
|
||||
if (result.Is(AddInstanceResult.Success)) {
|
||||
using (var scope = databaseProvider.CreateScope()) {
|
||||
InstanceEntity entity = scope.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
|
||||
|
||||
@ -88,20 +93,13 @@ public sealed class InstanceManager {
|
||||
}
|
||||
|
||||
Logger.Information("Added instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agentName);
|
||||
return InstanceActionResult.Concrete(AddInstanceResult.Success);
|
||||
}
|
||||
else {
|
||||
instances.ByGuid.Remove(configuration.InstanceGuid);
|
||||
|
||||
var result = reply.Map(static result => result switch {
|
||||
ConfigureInstanceResult.InstanceLimitExceeded => AddInstanceResult.AgentInstanceLimitExceeded,
|
||||
ConfigureInstanceResult.MemoryLimitExceeded => AddInstanceResult.AgentMemoryLimitExceeded,
|
||||
_ => AddInstanceResult.UnknownError
|
||||
});
|
||||
|
||||
Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agentName, result.ToSentence(AddInstanceResultExtensions.ToSentence));
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public ImmutableDictionary<Guid, string> GetInstanceNames() {
|
||||
|
@ -70,6 +70,11 @@ public sealed class MessageToServerListener : IMessageToServerListener {
|
||||
return NoReply.Instance;
|
||||
}
|
||||
|
||||
public async Task<NoReply> HandleReportAgentStatus(ReportAgentStatusMessage message) {
|
||||
agentManager.SetAgentStats(await WaitForAgentGuid(), message.RunningInstanceCount, message.RunningInstanceMemory);
|
||||
return NoReply.Instance;
|
||||
}
|
||||
|
||||
public Task<NoReply> HandleReportInstanceStatus(ReportInstanceStatusMessage message) {
|
||||
instanceManager.SetInstanceState(message.InstanceGuid, message.InstanceStatus);
|
||||
return Task.FromResult(NoReply.Instance);
|
||||
|
@ -1,10 +1,8 @@
|
||||
@page "/agents"
|
||||
@using System.Collections.Immutable
|
||||
@using Phantom.Server.Services.Agents
|
||||
@using Phantom.Utils.Collections
|
||||
@implements IDisposable
|
||||
@inject AgentManager AgentManager
|
||||
@inject AgentStatsManager AgentStatsManager
|
||||
|
||||
<h1>Agents</h1>
|
||||
|
||||
@ -23,9 +21,8 @@
|
||||
@if (!agentTable.IsEmpty) {
|
||||
<tbody>
|
||||
@foreach (var agent in agentTable) {
|
||||
var stats = agentStats.TryGetValue(agent.Guid, out var s) ? s : null;
|
||||
var usedInstances = stats?.UsedInstances;
|
||||
var usedMemory = stats?.UsedMemory.InMegabytes;
|
||||
var usedInstances = agent.Stats?.RunningInstanceCount;
|
||||
var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes;
|
||||
|
||||
<tr>
|
||||
<td>@agent.Name</td>
|
||||
@ -78,7 +75,6 @@
|
||||
@code {
|
||||
|
||||
private readonly Table<Agent, Guid> agentTable = new();
|
||||
private ImmutableDictionary<Guid, AgentStats> agentStats = ImmutableDictionary<Guid, AgentStats>.Empty;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
AgentManager.AgentsChanged.Subscribe(this, agents => {
|
||||
@ -86,16 +82,10 @@
|
||||
agentTable.UpdateFrom(sortedAgents, static agent => agent.Guid, static agent => agent, static (agent, _) => agent);
|
||||
InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
AgentStatsManager.AgentStatsChanged.Subscribe(this, agentStats => {
|
||||
this.agentStats = agentStats.ToImmutableDictionary(static stats => stats.Agent.Guid);
|
||||
InvokeAsync(StateHasChanged);
|
||||
});
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
AgentManager.AgentsChanged.Unsubscribe(this);
|
||||
AgentStatsManager.AgentStatsChanged.Unsubscribe(this);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,10 +4,10 @@
|
||||
@using Phantom.Server.Services.Agents
|
||||
@using Phantom.Server.Services.Audit
|
||||
@using Phantom.Server.Services.Instances
|
||||
@using Phantom.Server.Web.Components.Utils
|
||||
@using System.Collections.Immutable
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using Phantom.Server.Web.Components.Utils
|
||||
@using Phantom.Server.Web.Identity.Interfaces
|
||||
@using Phantom.Common.Data.Java
|
||||
@using Phantom.Common.Data
|
||||
@ -15,8 +15,8 @@
|
||||
@attribute [Authorize(Permission.CreateInstancesPolicy)]
|
||||
@inject INavigation Nav
|
||||
@inject MinecraftVersions MinecraftVersions
|
||||
@inject AgentManager AgentManager
|
||||
@inject AgentJavaRuntimesManager AgentJavaRuntimesManager
|
||||
@inject AgentStatsManager AgentStatsManager
|
||||
@inject InstanceManager InstanceManager
|
||||
@inject AuditLog AuditLog
|
||||
|
||||
@ -28,13 +28,13 @@
|
||||
<div class="col-xl-7 mb-3">
|
||||
<FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid">
|
||||
<option value="" selected>Select which agent will run the instance...</option>
|
||||
@foreach (var (agent, usedInstances, usedMemory) in form.AgentsByGuid.Values.OrderBy(static item => item.Agent.Name)) {
|
||||
@foreach (var agent in form.AgentsByGuid.Values.OrderBy(static agent => agent.Name)) {
|
||||
<option value="@agent.Guid">
|
||||
@agent.Name
|
||||
•
|
||||
@(usedInstances)/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances")
|
||||
@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances")
|
||||
•
|
||||
@(usedMemory.InMegabytes)/@(agent.MaxMemory.InMegabytes) MB RAM
|
||||
@(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(agent.MaxMemory.InMegabytes) MB RAM
|
||||
</option>
|
||||
}
|
||||
</FormSelectInput>
|
||||
@ -84,8 +84,8 @@
|
||||
</div>
|
||||
|
||||
@{
|
||||
string? allowedServerPorts = selectedAgent?.Agent.AllowedServerPorts?.ToString();
|
||||
string? allowedRconPorts = selectedAgent?.Agent.AllowedRconPorts?.ToString();
|
||||
string? allowedServerPorts = selectedAgent?.AllowedServerPorts?.ToString();
|
||||
string? allowedRconPorts = selectedAgent?.AllowedRconPorts?.ToString();
|
||||
}
|
||||
<div class="col-sm-6 col-xl-2 mb-3">
|
||||
<FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535">
|
||||
@ -116,14 +116,20 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-12 mb-3">
|
||||
@{ ushort maximumMemoryUnits = form.MaximumMemoryUnits; }
|
||||
<FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="2" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)">
|
||||
@{
|
||||
const ushort MinimumMemoryUnits = 2;
|
||||
ushort maximumMemoryUnits = form.MaximumMemoryUnits;
|
||||
double availableMemoryRatio = maximumMemoryUnits <= MinimumMemoryUnits ? 100.0 : 100.0 * (form.AvailableMemoryUnits - MinimumMemoryUnits) / (maximumMemoryUnits - MinimumMemoryUnits);
|
||||
// TODO not precise because the track is not centered on the track bar
|
||||
string memoryInputSplitVar = FormattableString.Invariant($"--range-split: {Math.Round(availableMemoryRatio, 2)}%");
|
||||
}
|
||||
<FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="@MinimumMemoryUnits" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)" class="form-range split-danger" style="@memoryInputSplitVar">
|
||||
<LabelFragment>
|
||||
@if (maximumMemoryUnits == 0) {
|
||||
<text>RAM</text>
|
||||
}
|
||||
else {
|
||||
<text>RAM • <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.AvailableMemory.InMegabytes) MB</code></text>
|
||||
<text>RAM • <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.MaxMemory.InMegabytes) MB</code></text>
|
||||
}
|
||||
</LabelFragment>
|
||||
</FormNumberInput>
|
||||
@ -154,13 +160,13 @@
|
||||
private bool IsSubmittable => form.SelectedAgentGuid != null && !form.EditContext.GetValidationMessages(form.EditContext.Field(nameof(CreateInstanceFormModel.SelectedAgentGuid))).Any();
|
||||
|
||||
private readonly Guid instanceGuid = Guid.NewGuid();
|
||||
|
||||
|
||||
private sealed class CreateInstanceFormModel : FormModel {
|
||||
public ImmutableDictionary<Guid, AgentStats> AgentsByGuid { get; }
|
||||
public ImmutableDictionary<Guid, Agent> AgentsByGuid { get; }
|
||||
private readonly ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> javaRuntimesByAgentGuid;
|
||||
|
||||
public CreateInstanceFormModel(AgentJavaRuntimesManager agentJavaRuntimesManager, AgentStatsManager agentStatsManager) {
|
||||
AgentsByGuid = agentStatsManager.GetOnlineAgentStats();
|
||||
public CreateInstanceFormModel(AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager) {
|
||||
AgentsByGuid = agentManager.GetAgents().Where(static agent => agent.Value.IsOnline).ToImmutableDictionary();
|
||||
javaRuntimesByAgentGuid = agentJavaRuntimesManager.All;
|
||||
}
|
||||
|
||||
@ -174,18 +180,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out AgentStats? stats) {
|
||||
return TryGet(AgentsByGuid, agentGuid, out stats);
|
||||
private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) {
|
||||
return TryGet(AgentsByGuid, agentGuid, out agent);
|
||||
}
|
||||
|
||||
public AgentStats? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agentStats) ? agentStats : null;
|
||||
public Agent? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null;
|
||||
|
||||
public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(javaRuntimesByAgentGuid, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty;
|
||||
public ushort MaximumMemoryUnits => SelectedAgent?.AvailableMemory.RawValue ?? 0;
|
||||
|
||||
public ushort MaximumMemoryUnits => SelectedAgent?.MaxMemory.RawValue ?? 0;
|
||||
public ushort AvailableMemoryUnits => SelectedAgent?.AvailableMemory?.RawValue ?? 0;
|
||||
private ushort selectedMemoryUnits = 4;
|
||||
|
||||
[Required(ErrorMessage = "You must select an agent.")]
|
||||
[AgentHasInstances(ErrorMessage = "This agent has no remaining instances.")]
|
||||
[AgentHasMemory(ErrorMessage = "This agent has no remaining RAM.")]
|
||||
public Guid? SelectedAgentGuid { get; set; } = null;
|
||||
|
||||
[Required(ErrorMessage = "Instance name is required.")]
|
||||
@ -220,24 +227,14 @@
|
||||
[JvmArgumentsMustBeValid(ErrorMessage = "JVM arguments are not valid.")]
|
||||
public string JvmArguments { get; set; } = string.Empty;
|
||||
|
||||
public sealed class AgentHasInstancesAttribute : FormValidationAttribute<CreateInstanceFormModel, Guid?> {
|
||||
protected override string FieldName => nameof(SelectedAgentGuid);
|
||||
protected override bool IsValid(CreateInstanceFormModel model, Guid? value) => model.TryGetAgent(value, out var agent) && agent.UsedInstances < agent.Agent.MaxInstances;
|
||||
}
|
||||
|
||||
public sealed class AgentHasMemoryAttribute : FormValidationAttribute<CreateInstanceFormModel, Guid?> {
|
||||
protected override string FieldName => nameof(SelectedAgentGuid);
|
||||
protected override bool IsValid(CreateInstanceFormModel model, Guid? value) => model.TryGetAgent(value, out var agent) && agent.AvailableMemory > RamAllocationUnits.Zero;
|
||||
}
|
||||
|
||||
public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<CreateInstanceFormModel, int> {
|
||||
protected override string FieldName => nameof(ServerPort);
|
||||
protected override bool IsValid(CreateInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Agent.AllowedServerPorts?.Contains((ushort) value) == true;
|
||||
protected override bool IsValid(CreateInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedServerPorts?.Contains((ushort) value) == true;
|
||||
}
|
||||
|
||||
public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<CreateInstanceFormModel, int> {
|
||||
protected override string FieldName => nameof(RconPort);
|
||||
protected override bool IsValid(CreateInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Agent.AllowedRconPorts?.Contains((ushort) value) == true;
|
||||
protected override bool IsValid(CreateInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedRconPorts?.Contains((ushort) value) == true;
|
||||
}
|
||||
|
||||
public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<CreateInstanceFormModel, int?> {
|
||||
@ -256,7 +253,7 @@
|
||||
}
|
||||
|
||||
protected override void OnInitialized() {
|
||||
form = new CreateInstanceFormModel(AgentJavaRuntimesManager, AgentStatsManager);
|
||||
form = new CreateInstanceFormModel(AgentManager, AgentJavaRuntimesManager);
|
||||
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(CreateInstanceFormModel.SelectedAgentGuid), revalidated: nameof(CreateInstanceFormModel.MemoryUnits));
|
||||
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(CreateInstanceFormModel.SelectedAgentGuid), revalidated: nameof(CreateInstanceFormModel.JavaRuntimeGuid));
|
||||
form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(CreateInstanceFormModel.SelectedAgentGuid), revalidated: nameof(CreateInstanceFormModel.ServerPort));
|
||||
@ -293,7 +290,7 @@
|
||||
var javaRuntimeGuid = form.JavaRuntimeGuid.GetValueOrDefault();
|
||||
var jvmArguments = JvmArgumentsHelper.Split(form.JvmArguments);
|
||||
|
||||
var instance = new InstanceConfiguration(selectedAgent.Agent.Guid, instanceGuid, form.InstanceName, serverPort, rconPort, form.MinecraftVersion, form.MinecraftServerKind, memoryAllocation, javaRuntimeGuid, jvmArguments, LaunchAutomatically: false);
|
||||
var instance = new InstanceConfiguration(selectedAgent.Guid, instanceGuid, form.InstanceName, serverPort, rconPort, form.MinecraftVersion, form.MinecraftServerKind, memoryAllocation, javaRuntimeGuid, jvmArguments, LaunchAutomatically: false);
|
||||
var result = await InstanceManager.AddInstance(instance);
|
||||
if (result.Is(AddInstanceResult.Success)) {
|
||||
await AuditLog.AddInstanceCreatedEvent(instance.InstanceGuid);
|
||||
|
@ -2,6 +2,10 @@
|
||||
@import url('../lib/openiconic/css/open-iconic-bootstrap.min.css');
|
||||
@import url('./blazor.css');
|
||||
|
||||
:root {
|
||||
--range-split: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@ -57,6 +61,11 @@ code {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.form-range.split-danger::-moz-range-track,
|
||||
.form-range.split-danger::-webkit-slider-runnable-track {
|
||||
background: linear-gradient(to right, #dfd7ca 0%, #dfd7ca var(--range-split), #bf8282 var(--range-split), #bf8282 100%);
|
||||
}
|
||||
|
||||
.form-submit-errors {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
|
@ -29,7 +29,6 @@ sealed class WebConfigurator : WebLauncher.IConfigurator {
|
||||
services.AddSingleton(agentToken);
|
||||
services.AddSingleton<AgentManager>();
|
||||
services.AddSingleton<AgentJavaRuntimesManager>();
|
||||
services.AddSingleton<AgentStatsManager>();
|
||||
services.AddSingleton<InstanceManager>();
|
||||
services.AddSingleton<InstanceLogManager>();
|
||||
services.AddSingleton<MinecraftVersions>();
|
||||
|
@ -199,6 +199,29 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull {
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveAll(Predicate<KeyValuePair<TKey, TValue>> removeCondition) {
|
||||
rwLock.EnterUpgradeableReadLock();
|
||||
try {
|
||||
var keysToRemove = dict.Where(kvp => removeCondition(kvp)).Select(static kvp => kvp.Key).ToImmutableHashSet();
|
||||
if (keysToRemove.IsEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
rwLock.EnterWriteLock();
|
||||
try {
|
||||
foreach (var key in keysToRemove) {
|
||||
dict.Remove(key);
|
||||
}
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
rwLock.ExitWriteLock();
|
||||
}
|
||||
} finally {
|
||||
rwLock.ExitUpgradeableReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
public ImmutableDictionary<TKey, TValue> ToImmutable() {
|
||||
rwLock.EnterReadLock();
|
||||
try {
|
||||
|
@ -84,6 +84,10 @@ public sealed class RwLockedObservableDictionary<TKey, TValue> where TKey : notn
|
||||
return FireCollectionChangedIf(dict.RemoveIf(key, removeCondition));
|
||||
}
|
||||
|
||||
public bool RemoveAll(Predicate<KeyValuePair<TKey, TValue>> removeCondition) {
|
||||
return FireCollectionChangedIf(dict.RemoveAll(removeCondition));
|
||||
}
|
||||
|
||||
public ImmutableDictionary<TKey, TValue> ToImmutable() {
|
||||
return dict.ToImmutable();
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user