mirror of
				https://github.com/chylex/Minecraft-Phantom-Panel.git
				synced 2025-11-03 01:31:29 +01:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			d50119d666
			...
			d307dbb6e0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						d307dbb6e0
	
				 | 
					
					
						|||
| 
						
						
							
						
						a6acd7dec9
	
				 | 
					
					
						|||
| 
						
						
							
						
						524e27bd29
	
				 | 
					
					
						|||
| 
						
						
							
						
						7a209d1d71
	
				 | 
					
					
						|||
| 
						
						
							
						
						71a5babb73
	
				 | 
					
					
						|||
| 
						
						
							
						
						125239b48d
	
				 | 
					
					
						|||
| 
						
						
							
						
						81bcb91566
	
				 | 
					
					
						|||
| 
						
						
							
						
						b71bc56fc2
	
				 | 
					
					
						
@@ -11,6 +11,7 @@ public enum AuditEventType {
 | 
				
			|||||||
	UserRolesChanged,
 | 
						UserRolesChanged,
 | 
				
			||||||
	UserDeleted,
 | 
						UserDeleted,
 | 
				
			||||||
	InstanceCreated,
 | 
						InstanceCreated,
 | 
				
			||||||
 | 
						InstanceEdited,
 | 
				
			||||||
	InstanceLaunched,
 | 
						InstanceLaunched,
 | 
				
			||||||
	InstanceStopped,
 | 
						InstanceStopped,
 | 
				
			||||||
	InstanceCommandExecuted
 | 
						InstanceCommandExecuted
 | 
				
			||||||
@@ -26,6 +27,7 @@ public static partial class AuditEventCategoryExtensions {
 | 
				
			|||||||
		{ AuditEventType.UserRolesChanged,          AuditSubjectType.User },
 | 
							{ AuditEventType.UserRolesChanged,          AuditSubjectType.User },
 | 
				
			||||||
		{ AuditEventType.UserDeleted,               AuditSubjectType.User },
 | 
							{ AuditEventType.UserDeleted,               AuditSubjectType.User },
 | 
				
			||||||
		{ AuditEventType.InstanceCreated,           AuditSubjectType.Instance },
 | 
							{ AuditEventType.InstanceCreated,           AuditSubjectType.Instance },
 | 
				
			||||||
 | 
							{ AuditEventType.InstanceEdited,            AuditSubjectType.Instance },
 | 
				
			||||||
		{ AuditEventType.InstanceLaunched,          AuditSubjectType.Instance },
 | 
							{ AuditEventType.InstanceLaunched,          AuditSubjectType.Instance },
 | 
				
			||||||
		{ AuditEventType.InstanceStopped,           AuditSubjectType.Instance },
 | 
							{ AuditEventType.InstanceStopped,           AuditSubjectType.Instance },
 | 
				
			||||||
		{ AuditEventType.InstanceCommandExecuted,   AuditSubjectType.Instance }
 | 
							{ AuditEventType.InstanceCommandExecuted,   AuditSubjectType.Instance }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,6 +24,11 @@ public sealed record Agent(
 | 
				
			|||||||
	
 | 
						
 | 
				
			||||||
	internal Agent(AgentInfo info) : this(info.Guid, info.Name, info.ProtocolVersion, info.BuildVersion, info.MaxInstances, info.MaxMemory, info.AllowedServerPorts, info.AllowedRconPorts) {}
 | 
						internal Agent(AgentInfo info) : this(info.Guid, info.Name, info.ProtocolVersion, info.BuildVersion, info.MaxInstances, info.MaxMemory, info.AllowedServerPorts, info.AllowedRconPorts) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						internal Agent AsOnline(DateTimeOffset lastPing) => this with {
 | 
				
			||||||
 | 
							LastPing = lastPing,
 | 
				
			||||||
 | 
							IsOnline = Connection != null
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
						
 | 
				
			||||||
	internal Agent AsDisconnected() => this with {
 | 
						internal Agent AsDisconnected() => this with {
 | 
				
			||||||
		IsOnline = false
 | 
							IsOnline = false
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -101,7 +101,7 @@ public sealed class AgentManager {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	internal void NotifyAgentIsAlive(Guid agentGuid) {
 | 
						internal void NotifyAgentIsAlive(Guid agentGuid) {
 | 
				
			||||||
		agents.ByGuid.TryReplace(agentGuid, static agent => agent with { LastPing = DateTimeOffset.Now });
 | 
							agents.ByGuid.TryReplace(agentGuid, static agent => agent.AsOnline(DateTimeOffset.Now));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	internal void SetAgentStats(Guid agentGuid, int runningInstanceCount, RamAllocationUnits runningInstanceMemory) {
 | 
						internal void SetAgentStats(Guid agentGuid, int runningInstanceCount, RamAllocationUnits runningInstanceMemory) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -50,6 +50,10 @@ public sealed partial class AuditLog {
 | 
				
			|||||||
		return AddEvent(AuditEventType.InstanceCreated, instanceGuid.ToString());
 | 
							return AddEvent(AuditEventType.InstanceCreated, instanceGuid.ToString());
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public Task AddInstanceEditedEvent(Guid instanceGuid) {
 | 
				
			||||||
 | 
							return AddEvent(AuditEventType.InstanceEdited, instanceGuid.ToString());
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						
 | 
				
			||||||
	public Task AddInstanceLaunchedEvent(Guid instanceGuid) {
 | 
						public Task AddInstanceLaunchedEvent(Guid instanceGuid) {
 | 
				
			||||||
		return AddEvent(AuditEventType.InstanceLaunched, instanceGuid.ToString());
 | 
							return AddEvent(AuditEventType.InstanceLaunched, instanceGuid.ToString());
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,22 +0,0 @@
 | 
				
			|||||||
namespace Phantom.Server.Services.Instances;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public enum AddInstanceResult : byte {
 | 
					 | 
				
			||||||
	UnknownError,
 | 
					 | 
				
			||||||
	Success,
 | 
					 | 
				
			||||||
	InstanceAlreadyExists,
 | 
					 | 
				
			||||||
	InstanceNameMustNotBeEmpty,
 | 
					 | 
				
			||||||
	InstanceMemoryMustNotBeZero,
 | 
					 | 
				
			||||||
	AgentNotFound
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public static class AddInstanceResultExtensions {
 | 
					 | 
				
			||||||
	public static string ToSentence(this AddInstanceResult reason) {
 | 
					 | 
				
			||||||
		return reason switch {
 | 
					 | 
				
			||||||
			AddInstanceResult.Success                     => "Success.",
 | 
					 | 
				
			||||||
			AddInstanceResult.InstanceNameMustNotBeEmpty  => "Instance name must not be empty.",
 | 
					 | 
				
			||||||
			AddInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.",
 | 
					 | 
				
			||||||
			AddInstanceResult.AgentNotFound               => "Agent not found.",
 | 
					 | 
				
			||||||
			_                                             => "Unknown error."
 | 
					 | 
				
			||||||
		};
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					namespace Phantom.Server.Services.Instances;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public enum AddOrEditInstanceResult : byte {
 | 
				
			||||||
 | 
						UnknownError,
 | 
				
			||||||
 | 
						Success,
 | 
				
			||||||
 | 
						InstanceNameMustNotBeEmpty,
 | 
				
			||||||
 | 
						InstanceMemoryMustNotBeZero,
 | 
				
			||||||
 | 
						AgentNotFound
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public static class AddOrEditInstanceResultExtensions {
 | 
				
			||||||
 | 
						public static string ToSentence(this AddOrEditInstanceResult reason) {
 | 
				
			||||||
 | 
							return reason switch {
 | 
				
			||||||
 | 
								AddOrEditInstanceResult.Success                     => "Success.",
 | 
				
			||||||
 | 
								AddOrEditInstanceResult.InstanceNameMustNotBeEmpty  => "Instance name must not be empty.",
 | 
				
			||||||
 | 
								AddOrEditInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.",
 | 
				
			||||||
 | 
								AddOrEditInstanceResult.AgentNotFound               => "Agent not found.",
 | 
				
			||||||
 | 
								_                                                   => "Unknown error."
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,4 +1,6 @@
 | 
				
			|||||||
using System.Collections.Immutable;
 | 
					using System.Collections.Immutable;
 | 
				
			||||||
 | 
					using System.Diagnostics.CodeAnalysis;
 | 
				
			||||||
 | 
					using Phantom.Common.Data;
 | 
				
			||||||
using Phantom.Common.Data.Instance;
 | 
					using Phantom.Common.Data.Instance;
 | 
				
			||||||
using Phantom.Common.Data.Minecraft;
 | 
					using Phantom.Common.Data.Minecraft;
 | 
				
			||||||
using Phantom.Common.Data.Replies;
 | 
					using Phantom.Common.Data.Replies;
 | 
				
			||||||
@@ -25,6 +27,7 @@ public sealed class InstanceManager {
 | 
				
			|||||||
	private readonly CancellationToken cancellationToken;
 | 
						private readonly CancellationToken cancellationToken;
 | 
				
			||||||
	private readonly AgentManager agentManager;
 | 
						private readonly AgentManager agentManager;
 | 
				
			||||||
	private readonly DatabaseProvider databaseProvider;
 | 
						private readonly DatabaseProvider databaseProvider;
 | 
				
			||||||
 | 
						private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, DatabaseProvider databaseProvider) {
 | 
						public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, DatabaseProvider databaseProvider) {
 | 
				
			||||||
		this.cancellationToken = configuration.CancellationToken;
 | 
							this.cancellationToken = configuration.CancellationToken;
 | 
				
			||||||
@@ -55,27 +58,40 @@ public sealed class InstanceManager {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<InstanceActionResult<AddInstanceResult>> AddInstance(InstanceConfiguration configuration) {
 | 
						[SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")]
 | 
				
			||||||
 | 
						public async Task<InstanceActionResult<AddOrEditInstanceResult>> AddOrEditInstance(InstanceConfiguration configuration) {
 | 
				
			||||||
		var agent = agentManager.GetAgent(configuration.AgentGuid);
 | 
							var agent = agentManager.GetAgent(configuration.AgentGuid);
 | 
				
			||||||
		if (agent == null) {
 | 
							if (agent == null) {
 | 
				
			||||||
			return InstanceActionResult.Concrete(AddInstanceResult.AgentNotFound);
 | 
								return InstanceActionResult.Concrete(AddOrEditInstanceResult.AgentNotFound);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var instance = new Instance(configuration);
 | 
							if (string.IsNullOrWhiteSpace(configuration.InstanceName)) {
 | 
				
			||||||
		if (!instances.ByGuid.TryAdd(instance.Configuration.InstanceGuid, instance)) {
 | 
								return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceNameMustNotBeEmpty);
 | 
				
			||||||
			return InstanceActionResult.Concrete(AddInstanceResult.InstanceAlreadyExists);
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					 | 
				
			||||||
		var agentName = agent.Name;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		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)) {
 | 
							if (configuration.MemoryAllocation <= RamAllocationUnits.Zero) {
 | 
				
			||||||
			using (var scope = databaseProvider.CreateScope()) {
 | 
								return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceMemoryMustNotBeZero);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							InstanceActionResult<AddOrEditInstanceResult> result;
 | 
				
			||||||
 | 
							bool isNewInstance;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							await modifyInstancesSemaphore.WaitAsync(cancellationToken);
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								var instance = new Instance(configuration);
 | 
				
			||||||
 | 
								instances.ByGuid.AddOrReplace(instance.Configuration.InstanceGuid, instance, out var oldInstance);
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								var reply = await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, new ConfigureInstanceMessage(configuration), TimeSpan.FromSeconds(10));
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								result = reply.DidNotReplyIfNull().Map(static result => result switch {
 | 
				
			||||||
 | 
									ConfigureInstanceResult.Success => AddOrEditInstanceResult.Success,
 | 
				
			||||||
 | 
									_                               => AddOrEditInstanceResult.UnknownError
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								isNewInstance = oldInstance == null;
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								if (result.Is(AddOrEditInstanceResult.Success)) {
 | 
				
			||||||
 | 
									using var scope = databaseProvider.CreateScope();
 | 
				
			||||||
				InstanceEntity entity = scope.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
 | 
									InstanceEntity entity = scope.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				entity.AgentGuid = configuration.AgentGuid;
 | 
									entity.AgentGuid = configuration.AgentGuid;
 | 
				
			||||||
@@ -91,14 +107,30 @@ public sealed class InstanceManager {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
				await scope.Ctx.SaveChangesAsync(cancellationToken);
 | 
									await scope.Ctx.SaveChangesAsync(cancellationToken);
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
								else if (isNewInstance) {
 | 
				
			||||||
			Logger.Information("Added instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agentName);
 | 
									instances.ByGuid.Remove(configuration.InstanceGuid);
 | 
				
			||||||
		}
 | 
								}
 | 
				
			||||||
		else {
 | 
							} finally {
 | 
				
			||||||
			instances.ByGuid.Remove(configuration.InstanceGuid);
 | 
								modifyInstancesSemaphore.Release();
 | 
				
			||||||
			Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agentName, result.ToSentence(AddInstanceResultExtensions.ToSentence));
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
 | 
							if (result.Is(AddOrEditInstanceResult.Success)) {
 | 
				
			||||||
 | 
								if (isNewInstance) {
 | 
				
			||||||
 | 
									Logger.Information("Added instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agent.Name);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								else {
 | 
				
			||||||
 | 
									Logger.Information("Edited instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agent.Name);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							else {
 | 
				
			||||||
 | 
								if (isNewInstance) {
 | 
				
			||||||
 | 
									Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence));
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								else {
 | 
				
			||||||
 | 
									Logger.Information("Failed editing instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence));
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return result;
 | 
							return result;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -106,8 +138,8 @@ public sealed class InstanceManager {
 | 
				
			|||||||
		return instances.ByGuid.ToImmutable<string>(static instance => instance.Configuration.InstanceName);
 | 
							return instances.ByGuid.ToImmutable<string>(static instance => instance.Configuration.InstanceName);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private Instance? GetInstance(Guid instanceGuid) {
 | 
						public InstanceConfiguration? GetInstanceConfiguration(Guid instanceGuid) {
 | 
				
			||||||
		return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? instance : null;
 | 
							return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? instance.Configuration : null;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	internal void SetInstanceState(Guid instanceGuid, IInstanceStatus instanceStatus) {
 | 
						internal void SetInstanceState(Guid instanceGuid, IInstanceStatus instanceStatus) {
 | 
				
			||||||
@@ -123,48 +155,48 @@ public sealed class InstanceManager {
 | 
				
			|||||||
		return reply.DidNotReplyIfNull();
 | 
							return reply.DidNotReplyIfNull();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private async Task<InstanceActionResult<TReply>> SendInstanceActionMessage<TMessage, TReply>(Guid instanceGuid, TMessage message) where TMessage : IMessageToAgent<InstanceActionResult<TReply>> {
 | 
				
			||||||
 | 
							return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? await SendInstanceActionMessage<TMessage, TReply>(instance, message) : InstanceActionResult.General<TReply>(InstanceActionGeneralResult.InstanceDoesNotExist);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid instanceGuid) {
 | 
						public async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid instanceGuid) {
 | 
				
			||||||
		var instance = GetInstance(instanceGuid);
 | 
							var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(instanceGuid, new LaunchInstanceMessage(instanceGuid));
 | 
				
			||||||
		if (instance == null) {
 | 
							if (result.Is(LaunchInstanceResult.LaunchInitiated)) {
 | 
				
			||||||
			return InstanceActionResult.General<LaunchInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist);
 | 
								await SetInstanceShouldLaunchAutomatically(instanceGuid, true);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		await SetInstanceShouldLaunchAutomatically(instanceGuid, true);
 | 
							return result;
 | 
				
			||||||
 | 
					 | 
				
			||||||
		return await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(instance, new LaunchInstanceMessage(instanceGuid));
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
 | 
						public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
 | 
				
			||||||
		var instance = GetInstance(instanceGuid);
 | 
							var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(instanceGuid, new StopInstanceMessage(instanceGuid, stopStrategy));
 | 
				
			||||||
		if (instance == null) {
 | 
							if (result.Is(StopInstanceResult.StopInitiated)) {
 | 
				
			||||||
			return InstanceActionResult.General<StopInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist);
 | 
								await SetInstanceShouldLaunchAutomatically(instanceGuid, false);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		await SetInstanceShouldLaunchAutomatically(instanceGuid, false);
 | 
							return result;
 | 
				
			||||||
 | 
					 | 
				
			||||||
		return await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(instance, new StopInstanceMessage(instanceGuid, stopStrategy));
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private async Task SetInstanceShouldLaunchAutomatically(Guid instanceGuid, bool shouldLaunchAutomatically) {
 | 
						private async Task SetInstanceShouldLaunchAutomatically(Guid instanceGuid, bool shouldLaunchAutomatically) {
 | 
				
			||||||
		instances.ByGuid.TryReplace(instanceGuid, instance => instance with {
 | 
							await modifyInstancesSemaphore.WaitAsync(cancellationToken);
 | 
				
			||||||
			Configuration = instance.Configuration with { LaunchAutomatically = shouldLaunchAutomatically }
 | 
							try {
 | 
				
			||||||
		});
 | 
								instances.ByGuid.TryReplace(instanceGuid, instance => instance with {
 | 
				
			||||||
 | 
									Configuration = instance.Configuration with { LaunchAutomatically = shouldLaunchAutomatically }
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		using var scope = databaseProvider.CreateScope();
 | 
								using var scope = databaseProvider.CreateScope();
 | 
				
			||||||
		var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken);
 | 
								var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken);
 | 
				
			||||||
		if (entity != null) {
 | 
								if (entity != null) {
 | 
				
			||||||
			entity.LaunchAutomatically = shouldLaunchAutomatically;
 | 
									entity.LaunchAutomatically = shouldLaunchAutomatically;
 | 
				
			||||||
			await scope.Ctx.SaveChangesAsync(cancellationToken);
 | 
									await scope.Ctx.SaveChangesAsync(cancellationToken);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							} finally {
 | 
				
			||||||
 | 
								modifyInstancesSemaphore.Release();
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
 | 
						public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
 | 
				
			||||||
		var instance = GetInstance(instanceGuid);
 | 
							return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
 | 
				
			||||||
		if (instance == null) {
 | 
					 | 
				
			||||||
			return InstanceActionResult.General<SendCommandToInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instance, new SendCommandToInstanceMessage(instanceGuid, command));
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	internal ImmutableArray<InstanceConfiguration> GetInstanceConfigurationsForAgent(Guid agentGuid) {
 | 
						internal ImmutableArray<InstanceConfiguration> GetInstanceConfigurationsForAgent(Guid agentGuid) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@
 | 
				
			|||||||
      <Column Width=" 90px; 19%" Class="text-end">Instances</Column>
 | 
					      <Column Width=" 90px; 19%" Class="text-end">Instances</Column>
 | 
				
			||||||
      <Column Width="145px; 21%" Class="text-end">Memory</Column>
 | 
					      <Column Width="145px; 21%" Class="text-end">Memory</Column>
 | 
				
			||||||
      <Column Width="180px;  8%">Version</Column>
 | 
					      <Column Width="180px;  8%">Version</Column>
 | 
				
			||||||
      <Column Width="315px">Identifier</Column>
 | 
					      <Column Width="320px">Identifier</Column>
 | 
				
			||||||
      <Column Width="100px;  8%" Class="text-center">Status</Column>
 | 
					      <Column Width="100px;  8%" Class="text-center">Status</Column>
 | 
				
			||||||
      <Column Width="215px" Class="text-end">Last Ping</Column>
 | 
					      <Column Width="215px" Class="text-end">Last Ping</Column>
 | 
				
			||||||
    </tr>
 | 
					    </tr>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,304 +1,5 @@
 | 
				
			|||||||
@page "/instances/create"
 | 
					@page "/instances/create"
 | 
				
			||||||
@using Phantom.Common.Data.Minecraft
 | 
					 | 
				
			||||||
@using Phantom.Common.Minecraft
 | 
					 | 
				
			||||||
@using Phantom.Server.Services.Agents
 | 
					 | 
				
			||||||
@using Phantom.Server.Services.Audit
 | 
					 | 
				
			||||||
@using Phantom.Server.Services.Instances
 | 
					 | 
				
			||||||
@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
 | 
					 | 
				
			||||||
@using Phantom.Common.Data.Instance
 | 
					 | 
				
			||||||
@attribute [Authorize(Permission.CreateInstancesPolicy)]
 | 
					@attribute [Authorize(Permission.CreateInstancesPolicy)]
 | 
				
			||||||
@inject INavigation Nav
 | 
					 | 
				
			||||||
@inject MinecraftVersions MinecraftVersions
 | 
					 | 
				
			||||||
@inject AgentManager AgentManager
 | 
					 | 
				
			||||||
@inject AgentJavaRuntimesManager AgentJavaRuntimesManager
 | 
					 | 
				
			||||||
@inject InstanceManager InstanceManager
 | 
					 | 
				
			||||||
@inject AuditLog AuditLog
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
<h1>New Instance</h1>
 | 
					<h1>New Instance</h1>
 | 
				
			||||||
 | 
					<InstanceAddOrEditForm EditedInstanceConfiguration="null" />
 | 
				
			||||||
<Form Model="form" OnSubmit="Submit">
 | 
					 | 
				
			||||||
  @{ var selectedAgent = form.SelectedAgent; }
 | 
					 | 
				
			||||||
  <div class="row">
 | 
					 | 
				
			||||||
    <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 in form.AgentsByGuid.Values.OrderBy(static agent => agent.Name)) {
 | 
					 | 
				
			||||||
          <option value="@agent.Guid">
 | 
					 | 
				
			||||||
            @agent.Name
 | 
					 | 
				
			||||||
            •
 | 
					 | 
				
			||||||
            @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances")
 | 
					 | 
				
			||||||
            •
 | 
					 | 
				
			||||||
            @(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(agent.MaxMemory.InMegabytes) MB RAM
 | 
					 | 
				
			||||||
          </option>
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      </FormSelectInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    <div class="col-xl-5 mb-3">
 | 
					 | 
				
			||||||
      <FormTextInput Id="instance-name" Label="Instance Name" @bind-Value="form.InstanceName" />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <div class="row">
 | 
					 | 
				
			||||||
    <div class="col-sm-6 col-xl-2 mb-3">
 | 
					 | 
				
			||||||
      <FormSelectInput Id="instance-server-kind" Label="Server Software" @bind-Value="form.MinecraftServerKind">
 | 
					 | 
				
			||||||
        @foreach (var kind in Enum.GetValues<MinecraftServerKind>()) {
 | 
					 | 
				
			||||||
          <option value="@kind">@kind</option>
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      </FormSelectInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    <div class="col-sm-6 col-xl-3 mb-3">
 | 
					 | 
				
			||||||
      <FormSelectInput Id="instance-minecraft-version" Label="Minecraft Version" @bind-Value="form.MinecraftVersion">
 | 
					 | 
				
			||||||
        <ChildContent>
 | 
					 | 
				
			||||||
          @foreach (var version in availableMinecraftVersions) {
 | 
					 | 
				
			||||||
            <option value="@version.Id">@version.Id</option>
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        </ChildContent>
 | 
					 | 
				
			||||||
        <GroupContent>
 | 
					 | 
				
			||||||
          <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">@minecraftVersionType.ToNiceNamePlural()</button>
 | 
					 | 
				
			||||||
          <ul class="dropdown-menu dropdown-menu-end">
 | 
					 | 
				
			||||||
            @foreach (var versionType in MinecraftVersionTypes.WithServerJars) {
 | 
					 | 
				
			||||||
              <li>
 | 
					 | 
				
			||||||
                <button class="dropdown-item" @onclick="() => SetMinecraftVersionType(versionType)">@versionType.ToNiceNamePlural()</button>
 | 
					 | 
				
			||||||
              </li>
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          </ul>
 | 
					 | 
				
			||||||
        </GroupContent>
 | 
					 | 
				
			||||||
      </FormSelectInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    <div class="col-xl-3 mb-3">
 | 
					 | 
				
			||||||
      <FormSelectInput Id="instance-java-runtime" Label="Java Runtime" @bind-Value="form.JavaRuntimeGuid" disabled="@(form.JavaRuntimesForSelectedAgent.IsEmpty)">
 | 
					 | 
				
			||||||
        <option value="" selected>Select Java runtime...</option>
 | 
					 | 
				
			||||||
        @foreach (var (guid, runtime) in form.JavaRuntimesForSelectedAgent) {
 | 
					 | 
				
			||||||
          <option value="@guid">@runtime.DisplayName</option>
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      </FormSelectInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @{
 | 
					 | 
				
			||||||
      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">
 | 
					 | 
				
			||||||
        <LabelFragment>
 | 
					 | 
				
			||||||
          @if (string.IsNullOrEmpty(allowedServerPorts)) {
 | 
					 | 
				
			||||||
            <text>Server Port</text>
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          else {
 | 
					 | 
				
			||||||
            <text>Server Port <sup title="Allowed: @allowedServerPorts">[?]</sup></text>
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        </LabelFragment>
 | 
					 | 
				
			||||||
      </FormNumberInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    <div class="col-sm-6 col-xl-2 mb-3">
 | 
					 | 
				
			||||||
      <FormNumberInput Id="instance-rcon-port" @bind-Value="form.RconPort" min="0" max="65535">
 | 
					 | 
				
			||||||
        <LabelFragment>
 | 
					 | 
				
			||||||
          @if (string.IsNullOrEmpty(allowedRconPorts)) {
 | 
					 | 
				
			||||||
            <text>Rcon Port</text>
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
          else {
 | 
					 | 
				
			||||||
            <text>Rcon Port <sup title="Allowed: @allowedRconPorts">[?]</sup></text>
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        </LabelFragment>
 | 
					 | 
				
			||||||
      </FormNumberInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <div class="row">
 | 
					 | 
				
			||||||
    <div class="col-xl-12 mb-3">
 | 
					 | 
				
			||||||
      @{
 | 
					 | 
				
			||||||
        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?.MaxMemory.InMegabytes) MB</code></text>
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        </LabelFragment>
 | 
					 | 
				
			||||||
      </FormNumberInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <div class="row">
 | 
					 | 
				
			||||||
    <div class="mb-3">
 | 
					 | 
				
			||||||
      <FormTextInput Id="instance-jvm-arguments" Type="FormTextInputType.Textarea" @bind-Value="form.JvmArguments" rows="4">
 | 
					 | 
				
			||||||
        <LabelFragment>
 | 
					 | 
				
			||||||
          JVM Arguments <span class="text-black-50">(one per line)</span>
 | 
					 | 
				
			||||||
        </LabelFragment>
 | 
					 | 
				
			||||||
      </FormTextInput>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <FormButtonSubmit Label="Create Instance" class="btn btn-primary" disabled="@(!IsSubmittable)" />
 | 
					 | 
				
			||||||
  <FormSubmitError />
 | 
					 | 
				
			||||||
</Form>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@code {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private CreateInstanceFormModel form = null!;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private MinecraftVersionType minecraftVersionType = MinecraftVersionType.Release;
 | 
					 | 
				
			||||||
  private ImmutableArray<MinecraftVersion> availableMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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, Agent> AgentsByGuid { get; }
 | 
					 | 
				
			||||||
    private readonly ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> javaRuntimesByAgentGuid;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public CreateInstanceFormModel(AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager) {
 | 
					 | 
				
			||||||
      AgentsByGuid = agentManager.GetAgents().Where(static agent => agent.Value.IsOnline).ToImmutableDictionary();
 | 
					 | 
				
			||||||
      javaRuntimesByAgentGuid = agentJavaRuntimesManager.All;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private bool TryGet<TValue>(ImmutableDictionary<Guid, TValue> dictionary, Guid? agentGuid, [MaybeNullWhen(false)] out TValue value) {
 | 
					 | 
				
			||||||
      if (agentGuid == null) {
 | 
					 | 
				
			||||||
        value = default;
 | 
					 | 
				
			||||||
        return false;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      else {
 | 
					 | 
				
			||||||
        return dictionary.TryGetValue(agentGuid.Value, out value);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) {
 | 
					 | 
				
			||||||
      return TryGet(AgentsByGuid, agentGuid, out agent);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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?.MaxMemory.RawValue ?? 0;
 | 
					 | 
				
			||||||
    public ushort AvailableMemoryUnits => SelectedAgent?.AvailableMemory?.RawValue ?? 0;
 | 
					 | 
				
			||||||
    private ushort selectedMemoryUnits = 4;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [Required(ErrorMessage = "You must select an agent.")]
 | 
					 | 
				
			||||||
    public Guid? SelectedAgentGuid { get; set; } = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [Required(ErrorMessage = "Instance name is required.")]
 | 
					 | 
				
			||||||
    [StringLength(100, ErrorMessage = "Instance name must be at most 100 characters.")]
 | 
					 | 
				
			||||||
    public string InstanceName { get; set; } = string.Empty;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [Range(minimum: 0, maximum: 65535, ErrorMessage = "Server port must be between 0 and 65535.")]
 | 
					 | 
				
			||||||
    [ServerPortMustBeAllowed(ErrorMessage = "Server port is not allowed.")]
 | 
					 | 
				
			||||||
    public int ServerPort { get; set; } = 25565;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [Range(minimum: 0, maximum: 65535, ErrorMessage = "Rcon port must be between 0 and 65535.")]
 | 
					 | 
				
			||||||
    [RconPortMustBeAllowed(ErrorMessage = "Rcon port is not allowed.")]
 | 
					 | 
				
			||||||
    [RconPortMustDifferFromServerPort(ErrorMessage = "Rcon port must not be the same as Server port.")]
 | 
					 | 
				
			||||||
    public int RconPort { get; set; } = 25575;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public MinecraftServerKind MinecraftServerKind { get; set; } = MinecraftServerKind.Vanilla;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [Required(ErrorMessage = "You must select a Java runtime.")]
 | 
					 | 
				
			||||||
    public Guid? JavaRuntimeGuid { get; set; }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [Required(ErrorMessage = "You must select a Minecraft version.")]
 | 
					 | 
				
			||||||
    public string MinecraftVersion { get; set; } = string.Empty;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [Range(minimum: 0, maximum: RamAllocationUnits.MaximumUnits, ErrorMessage = "Memory is out of range.")]
 | 
					 | 
				
			||||||
    public ushort MemoryUnits {
 | 
					 | 
				
			||||||
      get => Math.Min(selectedMemoryUnits, MaximumMemoryUnits);
 | 
					 | 
				
			||||||
      set => selectedMemoryUnits = value;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public RamAllocationUnits? MemoryAllocation => new RamAllocationUnits(MemoryUnits);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [JvmArgumentsMustBeValid(ErrorMessage = "JVM arguments are not valid.")]
 | 
					 | 
				
			||||||
    public string JvmArguments { get; set; } = string.Empty;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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.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.AllowedRconPorts?.Contains((ushort) value) == true;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<CreateInstanceFormModel, int?> {
 | 
					 | 
				
			||||||
      protected override string FieldName => nameof(RconPort);
 | 
					 | 
				
			||||||
      protected override bool IsValid(CreateInstanceFormModel model, int? value) => value != model.ServerPort;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public sealed class JvmArgumentsMustBeValidAttribute : FormCustomValidationAttribute<CreateInstanceFormModel, string> {
 | 
					 | 
				
			||||||
      protected override string FieldName => nameof(JvmArguments);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      protected override ValidationResult? Validate(CreateInstanceFormModel model, string value) {
 | 
					 | 
				
			||||||
        var error = JvmArgumentsHelper.Validate(value);
 | 
					 | 
				
			||||||
        return error == null ? ValidationResult.Success : new ValidationResult(error.ToSentence());
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  protected override void OnInitialized() {
 | 
					 | 
				
			||||||
    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));
 | 
					 | 
				
			||||||
    form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(CreateInstanceFormModel.SelectedAgentGuid), revalidated: nameof(CreateInstanceFormModel.RconPort));
 | 
					 | 
				
			||||||
    form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(CreateInstanceFormModel.ServerPort), revalidated: nameof(CreateInstanceFormModel.RconPort));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  protected override async Task OnInitializedAsync() {
 | 
					 | 
				
			||||||
    await SetMinecraftVersionType(minecraftVersionType);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private async Task SetMinecraftVersionType(MinecraftVersionType type) {
 | 
					 | 
				
			||||||
    minecraftVersionType = type;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None);
 | 
					 | 
				
			||||||
    availableMinecraftVersions = allMinecraftVersions.Where(version => version.Type == type).ToImmutableArray();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!availableMinecraftVersions.IsEmpty) {
 | 
					 | 
				
			||||||
      form.MinecraftVersion = availableMinecraftVersions[0].Id;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private async Task Submit(EditContext context) {
 | 
					 | 
				
			||||||
    var selectedAgent = form.SelectedAgent;
 | 
					 | 
				
			||||||
    if (selectedAgent == null) {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await form.SubmitModel.StartSubmitting();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    var serverPort = (ushort) form.ServerPort;
 | 
					 | 
				
			||||||
    var rconPort = (ushort) form.RconPort;
 | 
					 | 
				
			||||||
    var memoryAllocation = form.MemoryAllocation ?? RamAllocationUnits.Zero;
 | 
					 | 
				
			||||||
    var javaRuntimeGuid = form.JavaRuntimeGuid.GetValueOrDefault();
 | 
					 | 
				
			||||||
    var jvmArguments = JvmArgumentsHelper.Split(form.JvmArguments);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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);
 | 
					 | 
				
			||||||
      Nav.NavigateTo("instances/" + instance.InstanceGuid);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    else {
 | 
					 | 
				
			||||||
      form.SubmitModel.StopSubmitting(result.ToSentence(AddInstanceResultExtensions.ToSentence));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,6 +22,9 @@ else {
 | 
				
			|||||||
      <span><!-- extra spacing --></span>
 | 
					      <span><!-- extra spacing --></span>
 | 
				
			||||||
    </PermissionView>
 | 
					    </PermissionView>
 | 
				
			||||||
    <InstanceStatusText Status="Instance.Status" />
 | 
					    <InstanceStatusText Status="Instance.Status" />
 | 
				
			||||||
 | 
					    <PermissionView Permission="Permission.CreateInstances">
 | 
				
			||||||
 | 
					      <a href="instances/@InstanceGuid/edit" class="btn btn-warning ms-auto">Edit Configuration</a>
 | 
				
			||||||
 | 
					    </PermissionView>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
  @if (lastError != null) {
 | 
					  @if (lastError != null) {
 | 
				
			||||||
    <p class="text-danger mt-2">@lastError</p>
 | 
					    <p class="text-danger mt-2">@lastError</p>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										28
									
								
								Server/Phantom.Server.Web/Pages/InstanceEdit.razor
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								Server/Phantom.Server.Web/Pages/InstanceEdit.razor
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					@page "/instances/{InstanceGuid:guid}/edit"
 | 
				
			||||||
 | 
					@attribute [Authorize(Permission.CreateInstancesPolicy)]
 | 
				
			||||||
 | 
					@using Phantom.Server.Services.Instances
 | 
				
			||||||
 | 
					@using Phantom.Common.Data.Instance
 | 
				
			||||||
 | 
					@inherits PhantomComponent
 | 
				
			||||||
 | 
					@inject InstanceManager InstanceManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@if (InstanceConfiguration == null) {
 | 
				
			||||||
 | 
					  <h1>Instance Not Found</h1>
 | 
				
			||||||
 | 
					  <p>Return to <a href="instances">all instances</a>.</p>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					else {
 | 
				
			||||||
 | 
					  <h1>Edit Instance: @InstanceConfiguration.InstanceName</h1>
 | 
				
			||||||
 | 
					  <InstanceAddOrEditForm EditedInstanceConfiguration="InstanceConfiguration" />
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [Parameter]
 | 
				
			||||||
 | 
					  public Guid InstanceGuid { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private InstanceConfiguration? InstanceConfiguration { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected override void OnInitialized() {
 | 
				
			||||||
 | 
					    InstanceConfiguration = InstanceManager.GetInstanceConfiguration(InstanceGuid);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -18,11 +18,11 @@
 | 
				
			|||||||
    <tr>
 | 
					    <tr>
 | 
				
			||||||
      <Column Width="200px; 28%">Agent</Column>
 | 
					      <Column Width="200px; 28%">Agent</Column>
 | 
				
			||||||
      <Column Width="200px; 28%">Name</Column>
 | 
					      <Column Width="200px; 28%">Name</Column>
 | 
				
			||||||
      <Column Width="120px; 11%">Version</Column>
 | 
					      <Column Width="130px; 11%">Version</Column>
 | 
				
			||||||
      <Column Width="110px;  8%" Class="text-center">Server Port</Column>
 | 
					      <Column Width="110px;  8%" Class="text-center">Server Port</Column>
 | 
				
			||||||
      <Column Width="110px;  8%" Class="text-center">Rcon Port</Column>
 | 
					      <Column Width="110px;  8%" Class="text-center">Rcon Port</Column>
 | 
				
			||||||
      <Column Width=" 85px;  8%" Class="text-end">Memory</Column>
 | 
					      <Column Width=" 90px;  8%" Class="text-end">Memory</Column>
 | 
				
			||||||
      <Column Width="315px">Identifier</Column>
 | 
					      <Column Width="320px">Identifier</Column>
 | 
				
			||||||
      <Column Width="200px;  9%">Status</Column>
 | 
					      <Column Width="200px;  9%">Status</Column>
 | 
				
			||||||
      <Column Width=" 75px">Actions</Column>
 | 
					      <Column Width=" 75px">Actions</Column>
 | 
				
			||||||
    </tr>
 | 
					    </tr>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,7 @@
 | 
				
			|||||||
    <table class="table align-middle">
 | 
					    <table class="table align-middle">
 | 
				
			||||||
      <thead>
 | 
					      <thead>
 | 
				
			||||||
        <tr>
 | 
					        <tr>
 | 
				
			||||||
          <Column Width="315px">Identifier</Column>
 | 
					          <Column Width="320px">Identifier</Column>
 | 
				
			||||||
          <Column Width="125px; 40%">Username</Column>
 | 
					          <Column Width="125px; 40%">Username</Column>
 | 
				
			||||||
          <Column Width="125px; 60%">Roles</Column>
 | 
					          <Column Width="125px; 60%">Roles</Column>
 | 
				
			||||||
          @if (canEdit) {
 | 
					          @if (canEdit) {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										341
									
								
								Server/Phantom.Server.Web/Shared/InstanceAddOrEditForm.razor
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										341
									
								
								Server/Phantom.Server.Web/Shared/InstanceAddOrEditForm.razor
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,341 @@
 | 
				
			|||||||
 | 
					@using Phantom.Common.Data.Minecraft
 | 
				
			||||||
 | 
					@using Phantom.Common.Minecraft
 | 
				
			||||||
 | 
					@using Phantom.Server.Services.Agents
 | 
				
			||||||
 | 
					@using Phantom.Server.Services.Audit
 | 
				
			||||||
 | 
					@using Phantom.Server.Services.Instances
 | 
				
			||||||
 | 
					@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.Instance
 | 
				
			||||||
 | 
					@using Phantom.Common.Data.Java
 | 
				
			||||||
 | 
					@using Phantom.Common.Data
 | 
				
			||||||
 | 
					@inject INavigation Nav
 | 
				
			||||||
 | 
					@inject MinecraftVersions MinecraftVersions
 | 
				
			||||||
 | 
					@inject AgentManager AgentManager
 | 
				
			||||||
 | 
					@inject AgentJavaRuntimesManager AgentJavaRuntimesManager
 | 
				
			||||||
 | 
					@inject InstanceManager InstanceManager
 | 
				
			||||||
 | 
					@inject AuditLog AuditLog
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<Form Model="form" OnSubmit="AddOrEditInstance">
 | 
				
			||||||
 | 
					  @{ var selectedAgent = form.SelectedAgent; }
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-xl-7 mb-3">
 | 
				
			||||||
 | 
					      @{
 | 
				
			||||||
 | 
					        static RenderFragment GetAgentOption(Agent agent) {
 | 
				
			||||||
 | 
					          return @<option value="@agent.Guid">
 | 
				
			||||||
 | 
					                   @agent.Name
 | 
				
			||||||
 | 
					                   •
 | 
				
			||||||
 | 
					                   @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(agent.MaxInstances) @(agent.MaxInstances == 1 ? "Instance" : "Instances")
 | 
				
			||||||
 | 
					                   •
 | 
				
			||||||
 | 
					                   @(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(agent.MaxMemory.InMegabytes) MB RAM
 | 
				
			||||||
 | 
					                 </option>;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      @if (EditedInstanceConfiguration == null) {
 | 
				
			||||||
 | 
					        <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid">
 | 
				
			||||||
 | 
					          <option value="" selected>Select which agent will run the instance...</option>
 | 
				
			||||||
 | 
					          @foreach (var agent in form.AgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) {
 | 
				
			||||||
 | 
					            @GetAgentOption(agent)
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        </FormSelectInput>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      else {
 | 
				
			||||||
 | 
					        <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid" disabled="true">
 | 
				
			||||||
 | 
					          @if (form.SelectedAgentGuid is {} guid && form.AgentsByGuid.TryGetValue(guid, out var agent)) {
 | 
				
			||||||
 | 
					            @GetAgentOption(agent)
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        </FormSelectInput>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="col-xl-5 mb-3">
 | 
				
			||||||
 | 
					      <FormTextInput Id="instance-name" Label="Instance Name" @bind-Value="form.InstanceName" />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-sm-6 col-xl-2 mb-3">
 | 
				
			||||||
 | 
					      <FormSelectInput Id="instance-server-kind" Label="Server Software" @bind-Value="form.MinecraftServerKind">
 | 
				
			||||||
 | 
					        @foreach (var kind in Enum.GetValues<MinecraftServerKind>()) {
 | 
				
			||||||
 | 
					          <option value="@kind">@kind</option>
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      </FormSelectInput>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="col-sm-6 col-xl-3 mb-3">
 | 
				
			||||||
 | 
					      <FormSelectInput Id="instance-minecraft-version" Label="Minecraft Version" @bind-Value="form.MinecraftVersion">
 | 
				
			||||||
 | 
					        <ChildContent>
 | 
				
			||||||
 | 
					          @foreach (var version in availableMinecraftVersions) {
 | 
				
			||||||
 | 
					            <option value="@version.Id">@version.Id</option>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        </ChildContent>
 | 
				
			||||||
 | 
					        <GroupContent>
 | 
				
			||||||
 | 
					          <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">@minecraftVersionType.ToNiceNamePlural()</button>
 | 
				
			||||||
 | 
					          <ul class="dropdown-menu dropdown-menu-end">
 | 
				
			||||||
 | 
					            @foreach (var versionType in MinecraftVersionTypes.WithServerJars) {
 | 
				
			||||||
 | 
					              <li>
 | 
				
			||||||
 | 
					                <button type="button" class="dropdown-item" @onclick="() => SetMinecraftVersionType(versionType)">@versionType.ToNiceNamePlural()</button>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </GroupContent>
 | 
				
			||||||
 | 
					      </FormSelectInput>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="col-xl-3 mb-3">
 | 
				
			||||||
 | 
					      <FormSelectInput Id="instance-java-runtime" Label="Java Runtime" @bind-Value="form.JavaRuntimeGuid" disabled="@(form.JavaRuntimesForSelectedAgent.IsEmpty)">
 | 
				
			||||||
 | 
					        <option value="" selected>Select Java runtime...</option>
 | 
				
			||||||
 | 
					        @foreach (var (guid, runtime) in form.JavaRuntimesForSelectedAgent) {
 | 
				
			||||||
 | 
					          <option value="@guid">@runtime.DisplayName</option>
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      </FormSelectInput>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @{
 | 
				
			||||||
 | 
					      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">
 | 
				
			||||||
 | 
					        <LabelFragment>
 | 
				
			||||||
 | 
					          @if (string.IsNullOrEmpty(allowedServerPorts)) {
 | 
				
			||||||
 | 
					            <text>Server Port</text>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          else {
 | 
				
			||||||
 | 
					            <text>Server Port <sup title="Allowed: @allowedServerPorts">[?]</sup></text>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        </LabelFragment>
 | 
				
			||||||
 | 
					      </FormNumberInput>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <div class="col-sm-6 col-xl-2 mb-3">
 | 
				
			||||||
 | 
					      <FormNumberInput Id="instance-rcon-port" @bind-Value="form.RconPort" min="0" max="65535">
 | 
				
			||||||
 | 
					        <LabelFragment>
 | 
				
			||||||
 | 
					          @if (string.IsNullOrEmpty(allowedRconPorts)) {
 | 
				
			||||||
 | 
					            <text>Rcon Port</text>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          else {
 | 
				
			||||||
 | 
					            <text>Rcon Port <sup title="Allowed: @allowedRconPorts">[?]</sup></text>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        </LabelFragment>
 | 
				
			||||||
 | 
					      </FormNumberInput>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-xl-12 mb-3">
 | 
				
			||||||
 | 
					      @{
 | 
				
			||||||
 | 
					        const ushort MinimumMemoryUnits = 2;
 | 
				
			||||||
 | 
					        ushort maximumMemoryUnits = form.MaximumMemoryUnits;
 | 
				
			||||||
 | 
					        double availableMemoryRatio = maximumMemoryUnits <= MinimumMemoryUnits ? 100.0 : 100.0 * (form.AvailableMemoryUnits - MinimumMemoryUnits) / (maximumMemoryUnits - MinimumMemoryUnits);
 | 
				
			||||||
 | 
					        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?.MaxMemory.InMegabytes) MB</code></text>
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        </LabelFragment>
 | 
				
			||||||
 | 
					      </FormNumberInput>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="mb-3">
 | 
				
			||||||
 | 
					      <FormTextInput Id="instance-jvm-arguments" Type="FormTextInputType.Textarea" @bind-Value="form.JvmArguments" rows="4">
 | 
				
			||||||
 | 
					        <LabelFragment>
 | 
				
			||||||
 | 
					          JVM Arguments <span class="text-black-50">(one per line)</span>
 | 
				
			||||||
 | 
					        </LabelFragment>
 | 
				
			||||||
 | 
					      </FormTextInput>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <FormButtonSubmit Label="@(EditedInstanceConfiguration == null ? "Create Instance" : "Edit Instance")" class="btn btn-primary" disabled="@(!IsSubmittable)" />
 | 
				
			||||||
 | 
					  <FormSubmitError />
 | 
				
			||||||
 | 
					</Form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@code {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  [Parameter, EditorRequired]
 | 
				
			||||||
 | 
					  public InstanceConfiguration? EditedInstanceConfiguration { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private ConfigureInstanceFormModel form = null!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private MinecraftVersionType minecraftVersionType = MinecraftVersionType.Release;
 | 
				
			||||||
 | 
					  private ImmutableArray<MinecraftVersion> availableMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private bool IsSubmittable => form.SelectedAgentGuid != null && !form.EditContext.GetValidationMessages(form.EditContext.Field(nameof(ConfigureInstanceFormModel.SelectedAgentGuid))).Any();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private sealed class ConfigureInstanceFormModel : FormModel {
 | 
				
			||||||
 | 
					    public ImmutableDictionary<Guid, Agent> AgentsByGuid { get; }
 | 
				
			||||||
 | 
					    private readonly ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> javaRuntimesByAgentGuid;
 | 
				
			||||||
 | 
					    private readonly RamAllocationUnits? editedInstanceRamAllocation;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public ConfigureInstanceFormModel(AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, RamAllocationUnits? editedInstanceRamAllocation) {
 | 
				
			||||||
 | 
					      this.AgentsByGuid = agentManager.GetAgents().ToImmutableDictionary();
 | 
				
			||||||
 | 
					      this.javaRuntimesByAgentGuid = agentJavaRuntimesManager.All;
 | 
				
			||||||
 | 
					      this.editedInstanceRamAllocation = editedInstanceRamAllocation;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private bool TryGet<TValue>(ImmutableDictionary<Guid, TValue> dictionary, Guid? agentGuid, [MaybeNullWhen(false)] out TValue value) {
 | 
				
			||||||
 | 
					      if (agentGuid == null) {
 | 
				
			||||||
 | 
					        value = default;
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      else {
 | 
				
			||||||
 | 
					        return dictionary.TryGetValue(agentGuid.Value, out value);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) {
 | 
				
			||||||
 | 
					      return TryGet(AgentsByGuid, agentGuid, out agent);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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?.MaxMemory.RawValue ?? 0;
 | 
				
			||||||
 | 
					    public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits);
 | 
				
			||||||
 | 
					    private ushort selectedMemoryUnits = 4;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Required(ErrorMessage = "You must select an agent.")]
 | 
				
			||||||
 | 
					    public Guid? SelectedAgentGuid { get; set; } = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Required(ErrorMessage = "Instance name is required.")]
 | 
				
			||||||
 | 
					    [StringLength(100, ErrorMessage = "Instance name must be at most 100 characters.")]
 | 
				
			||||||
 | 
					    public string InstanceName { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Range(minimum: 0, maximum: 65535, ErrorMessage = "Server port must be between 0 and 65535.")]
 | 
				
			||||||
 | 
					    [ServerPortMustBeAllowed(ErrorMessage = "Server port is not allowed.")]
 | 
				
			||||||
 | 
					    public int ServerPort { get; set; } = 25565;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Range(minimum: 0, maximum: 65535, ErrorMessage = "Rcon port must be between 0 and 65535.")]
 | 
				
			||||||
 | 
					    [RconPortMustBeAllowed(ErrorMessage = "Rcon port is not allowed.")]
 | 
				
			||||||
 | 
					    [RconPortMustDifferFromServerPort(ErrorMessage = "Rcon port must not be the same as Server port.")]
 | 
				
			||||||
 | 
					    public int RconPort { get; set; } = 25575;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public MinecraftServerKind MinecraftServerKind { get; set; } = MinecraftServerKind.Vanilla;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Required(ErrorMessage = "You must select a Java runtime.")]
 | 
				
			||||||
 | 
					    public Guid? JavaRuntimeGuid { get; set; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Required(ErrorMessage = "You must select a Minecraft version.")]
 | 
				
			||||||
 | 
					    public string MinecraftVersion { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [Range(minimum: 0, maximum: RamAllocationUnits.MaximumUnits, ErrorMessage = "Memory is out of range.")]
 | 
				
			||||||
 | 
					    public ushort MemoryUnits {
 | 
				
			||||||
 | 
					      get => Math.Min(selectedMemoryUnits, MaximumMemoryUnits);
 | 
				
			||||||
 | 
					      set => selectedMemoryUnits = value;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public RamAllocationUnits? MemoryAllocation => new RamAllocationUnits(MemoryUnits);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    [JvmArgumentsMustBeValid(ErrorMessage = "JVM arguments are not valid.")]
 | 
				
			||||||
 | 
					    public string JvmArguments { get; set; } = string.Empty;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
 | 
				
			||||||
 | 
					      protected override string FieldName => nameof(ServerPort);
 | 
				
			||||||
 | 
					      protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedServerPorts?.Contains((ushort) value) == true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
 | 
				
			||||||
 | 
					      protected override string FieldName => nameof(RconPort);
 | 
				
			||||||
 | 
					      protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.AllowedRconPorts?.Contains((ushort) value) == true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> {
 | 
				
			||||||
 | 
					      protected override string FieldName => nameof(RconPort);
 | 
				
			||||||
 | 
					      protected override bool IsValid(ConfigureInstanceFormModel model, int? value) => value != model.ServerPort;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public sealed class JvmArgumentsMustBeValidAttribute : FormCustomValidationAttribute<ConfigureInstanceFormModel, string> {
 | 
				
			||||||
 | 
					      protected override string FieldName => nameof(JvmArguments);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      protected override ValidationResult? Validate(ConfigureInstanceFormModel model, string value) {
 | 
				
			||||||
 | 
					        var error = JvmArgumentsHelper.Validate(value);
 | 
				
			||||||
 | 
					        return error == null ? ValidationResult.Success : new ValidationResult(error.ToSentence());
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected override void OnInitialized() {
 | 
				
			||||||
 | 
					    form = new ConfigureInstanceFormModel(AgentManager, AgentJavaRuntimesManager, EditedInstanceConfiguration?.MemoryAllocation);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (EditedInstanceConfiguration != null) {
 | 
				
			||||||
 | 
					      form.SelectedAgentGuid = EditedInstanceConfiguration.AgentGuid;
 | 
				
			||||||
 | 
					      form.InstanceName = EditedInstanceConfiguration.InstanceName;
 | 
				
			||||||
 | 
					      form.ServerPort = EditedInstanceConfiguration.ServerPort;
 | 
				
			||||||
 | 
					      form.RconPort = EditedInstanceConfiguration.RconPort;
 | 
				
			||||||
 | 
					      form.MinecraftVersion = EditedInstanceConfiguration.MinecraftVersion;
 | 
				
			||||||
 | 
					      form.MinecraftServerKind = EditedInstanceConfiguration.MinecraftServerKind;
 | 
				
			||||||
 | 
					      form.MemoryUnits = EditedInstanceConfiguration.MemoryAllocation.RawValue;
 | 
				
			||||||
 | 
					      form.JavaRuntimeGuid = EditedInstanceConfiguration.JavaRuntimeGuid;
 | 
				
			||||||
 | 
					      form.JvmArguments = JvmArgumentsHelper.Join(EditedInstanceConfiguration.JvmArguments);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.MemoryUnits));
 | 
				
			||||||
 | 
					    form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.JavaRuntimeGuid));
 | 
				
			||||||
 | 
					    form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.ServerPort));
 | 
				
			||||||
 | 
					    form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.RconPort));
 | 
				
			||||||
 | 
					    form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.ServerPort), revalidated: nameof(ConfigureInstanceFormModel.RconPort));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected override async Task OnInitializedAsync() {
 | 
				
			||||||
 | 
					    if (EditedInstanceConfiguration != null) {
 | 
				
			||||||
 | 
					      var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None);
 | 
				
			||||||
 | 
					      minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await SetMinecraftVersionType(minecraftVersionType);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async Task SetMinecraftVersionType(MinecraftVersionType type) {
 | 
				
			||||||
 | 
					    minecraftVersionType = type;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None);
 | 
				
			||||||
 | 
					    availableMinecraftVersions = allMinecraftVersions.Where(version => version.Type == type).ToImmutableArray();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!availableMinecraftVersions.IsEmpty && !allMinecraftVersions.Any(version => version.Id == form.MinecraftVersion)) {
 | 
				
			||||||
 | 
					      form.MinecraftVersion = availableMinecraftVersions[0].Id;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async Task AddOrEditInstance(EditContext context) {
 | 
				
			||||||
 | 
					    var selectedAgent = form.SelectedAgent;
 | 
				
			||||||
 | 
					    if (selectedAgent == null) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await form.SubmitModel.StartSubmitting();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var instance = new InstanceConfiguration(
 | 
				
			||||||
 | 
					      EditedInstanceConfiguration?.AgentGuid ?? selectedAgent.Guid,
 | 
				
			||||||
 | 
					      EditedInstanceConfiguration?.InstanceGuid ?? Guid.NewGuid(),
 | 
				
			||||||
 | 
					      form.InstanceName,
 | 
				
			||||||
 | 
					      (ushort) form.ServerPort,
 | 
				
			||||||
 | 
					      (ushort) form.RconPort,
 | 
				
			||||||
 | 
					      form.MinecraftVersion,
 | 
				
			||||||
 | 
					      form.MinecraftServerKind,
 | 
				
			||||||
 | 
					      form.MemoryAllocation ?? RamAllocationUnits.Zero,
 | 
				
			||||||
 | 
					      form.JavaRuntimeGuid.GetValueOrDefault(),
 | 
				
			||||||
 | 
					      JvmArgumentsHelper.Split(form.JvmArguments),
 | 
				
			||||||
 | 
					      EditedInstanceConfiguration?.LaunchAutomatically ?? false
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var result = await InstanceManager.AddOrEditInstance(instance);
 | 
				
			||||||
 | 
					    if (result.Is(AddOrEditInstanceResult.Success)) {
 | 
				
			||||||
 | 
					      await (EditedInstanceConfiguration == null ? AuditLog.AddInstanceCreatedEvent(instance.InstanceGuid) : AuditLog.AddInstanceEditedEvent(instance.InstanceGuid));
 | 
				
			||||||
 | 
					      Nav.NavigateTo("instances/" + instance.InstanceGuid);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    else {
 | 
				
			||||||
 | 
					      form.SubmitModel.StopSubmitting(result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -45,6 +45,10 @@ code {
 | 
				
			|||||||
  word-break: break-word;
 | 
					  word-break: break-word;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn {
 | 
				
			||||||
 | 
					  text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.table {
 | 
					.table {
 | 
				
			||||||
  margin-top: 0.5rem;
 | 
					  margin-top: 0.5rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -61,8 +65,13 @@ code {
 | 
				
			|||||||
  height: 2.5rem;
 | 
					  height: 2.5rem;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.form-range.split-danger::-moz-range-track,
 | 
					.form-range.split-danger::-moz-range-track {
 | 
				
			||||||
 | 
					  width: calc(100% - 1rem);
 | 
				
			||||||
 | 
					  background: linear-gradient(to right, #dfd7ca 0%, #dfd7ca var(--range-split), #bf8282 var(--range-split), #bf8282 100%);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.form-range.split-danger::-webkit-slider-runnable-track {
 | 
					.form-range.split-danger::-webkit-slider-runnable-track {
 | 
				
			||||||
 | 
					  /* centering fix does not work in Chrome */
 | 
				
			||||||
  background: linear-gradient(to right, #dfd7ca 0%, #dfd7ca var(--range-split), #bf8282 var(--range-split), #bf8282 100%);
 | 
					  background: linear-gradient(to right, #dfd7ca 0%, #dfd7ca var(--range-split), #bf8282 var(--range-split), #bf8282 100%);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user