1
0
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:
chylex 2023-01-29 22:16:11 +01:00
parent e40459a039
commit dca52bb6ad
Signed by: chylex
GPG Key ID: 4DE42C8F19A80548
21 changed files with 189 additions and 166 deletions

View File

@ -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;

View File

@ -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) {

View File

@ -37,6 +37,8 @@ public sealed class MessageListener : IMessageToAgentListener {
}
await ServerMessaging.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
await agent.InstanceSessionManager.RefreshAgentStatus();
return NoReply.Instance;
}

View File

@ -1,7 +1,5 @@
namespace Phantom.Common.Data.Replies;
public enum ConfigureInstanceResult : byte {
Success,
InstanceLimitExceeded,
MemoryLimitExceeded
Success
}

View File

@ -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."
};
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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
};
}

View File

@ -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);

View File

@ -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
);

View File

@ -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);
}
}
}

View File

@ -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."
};
}

View File

@ -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() {

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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
&bullet;
@(usedInstances)/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances")
@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances")
&bullet;
@(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 &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.AvailableMemory.InMegabytes) MB</code></text>
<text>RAM &bullet; <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);

View File

@ -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;

View File

@ -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>();

View File

@ -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 {

View File

@ -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();
}