mirror of
				https://github.com/chylex/Minecraft-Phantom-Panel.git
				synced 2025-10-31 20:17:16 +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, | ||||
| 	UserDeleted, | ||||
| 	InstanceCreated, | ||||
| 	InstanceEdited, | ||||
| 	InstanceLaunched, | ||||
| 	InstanceStopped, | ||||
| 	InstanceCommandExecuted | ||||
| @@ -26,6 +27,7 @@ public static partial class AuditEventCategoryExtensions { | ||||
| 		{ AuditEventType.UserRolesChanged,          AuditSubjectType.User }, | ||||
| 		{ AuditEventType.UserDeleted,               AuditSubjectType.User }, | ||||
| 		{ AuditEventType.InstanceCreated,           AuditSubjectType.Instance }, | ||||
| 		{ AuditEventType.InstanceEdited,            AuditSubjectType.Instance }, | ||||
| 		{ AuditEventType.InstanceLaunched,          AuditSubjectType.Instance }, | ||||
| 		{ AuditEventType.InstanceStopped,           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 AsOnline(DateTimeOffset lastPing) => this with { | ||||
| 		LastPing = lastPing, | ||||
| 		IsOnline = Connection != null | ||||
| 	}; | ||||
| 	 | ||||
| 	internal Agent AsDisconnected() => this with { | ||||
| 		IsOnline = false | ||||
| 	}; | ||||
|   | ||||
| @@ -101,7 +101,7 @@ public sealed class AgentManager { | ||||
| 	} | ||||
|  | ||||
| 	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) { | ||||
|   | ||||
| @@ -50,6 +50,10 @@ public sealed partial class AuditLog { | ||||
| 		return AddEvent(AuditEventType.InstanceCreated, instanceGuid.ToString()); | ||||
| 	} | ||||
|  | ||||
| 	public Task AddInstanceEditedEvent(Guid instanceGuid) { | ||||
| 		return AddEvent(AuditEventType.InstanceEdited, instanceGuid.ToString()); | ||||
| 	} | ||||
| 	 | ||||
| 	public Task AddInstanceLaunchedEvent(Guid instanceGuid) { | ||||
| 		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.Diagnostics.CodeAnalysis; | ||||
| using Phantom.Common.Data; | ||||
| using Phantom.Common.Data.Instance; | ||||
| using Phantom.Common.Data.Minecraft; | ||||
| using Phantom.Common.Data.Replies; | ||||
| @@ -25,6 +27,7 @@ public sealed class InstanceManager { | ||||
| 	private readonly CancellationToken cancellationToken; | ||||
| 	private readonly AgentManager agentManager; | ||||
| 	private readonly DatabaseProvider databaseProvider; | ||||
| 	private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1); | ||||
|  | ||||
| 	public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, DatabaseProvider databaseProvider) { | ||||
| 		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); | ||||
| 		if (agent == null) { | ||||
| 			return InstanceActionResult.Concrete(AddInstanceResult.AgentNotFound); | ||||
| 			return InstanceActionResult.Concrete(AddOrEditInstanceResult.AgentNotFound); | ||||
| 		} | ||||
|  | ||||
| 		var instance = new Instance(configuration); | ||||
| 		if (!instances.ByGuid.TryAdd(instance.Configuration.InstanceGuid, instance)) { | ||||
| 			return InstanceActionResult.Concrete(AddInstanceResult.InstanceAlreadyExists); | ||||
| 		if (string.IsNullOrWhiteSpace(configuration.InstanceName)) { | ||||
| 			return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceNameMustNotBeEmpty); | ||||
| 		} | ||||
|  | ||||
| 		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)) { | ||||
| 			using (var scope = databaseProvider.CreateScope()) { | ||||
| 		if (configuration.MemoryAllocation <= RamAllocationUnits.Zero) { | ||||
| 			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); | ||||
|  | ||||
| 				entity.AgentGuid = configuration.AgentGuid; | ||||
| @@ -91,14 +107,30 @@ public sealed class InstanceManager { | ||||
|  | ||||
| 				await scope.Ctx.SaveChangesAsync(cancellationToken); | ||||
| 			} | ||||
|  | ||||
| 			Logger.Information("Added instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agentName); | ||||
| 		} | ||||
| 		else { | ||||
| 			instances.ByGuid.Remove(configuration.InstanceGuid); | ||||
| 			Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agentName, result.ToSentence(AddInstanceResultExtensions.ToSentence)); | ||||
| 			else if (isNewInstance) { | ||||
| 				instances.ByGuid.Remove(configuration.InstanceGuid); | ||||
| 			} | ||||
| 		} finally { | ||||
| 			modifyInstancesSemaphore.Release(); | ||||
| 		} | ||||
| 		 | ||||
| 		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; | ||||
| 	} | ||||
|  | ||||
| @@ -106,8 +138,8 @@ public sealed class InstanceManager { | ||||
| 		return instances.ByGuid.ToImmutable<string>(static instance => instance.Configuration.InstanceName); | ||||
| 	} | ||||
|  | ||||
| 	private Instance? GetInstance(Guid instanceGuid) { | ||||
| 		return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? instance : null; | ||||
| 	public InstanceConfiguration? GetInstanceConfiguration(Guid instanceGuid) { | ||||
| 		return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? instance.Configuration : null; | ||||
| 	} | ||||
|  | ||||
| 	internal void SetInstanceState(Guid instanceGuid, IInstanceStatus instanceStatus) { | ||||
| @@ -123,48 +155,48 @@ public sealed class InstanceManager { | ||||
| 		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) { | ||||
| 		var instance = GetInstance(instanceGuid); | ||||
| 		if (instance == null) { | ||||
| 			return InstanceActionResult.General<LaunchInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); | ||||
| 		var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(instanceGuid, new LaunchInstanceMessage(instanceGuid)); | ||||
| 		if (result.Is(LaunchInstanceResult.LaunchInitiated)) { | ||||
| 			await SetInstanceShouldLaunchAutomatically(instanceGuid, true); | ||||
| 		} | ||||
|  | ||||
| 		await SetInstanceShouldLaunchAutomatically(instanceGuid, true); | ||||
|  | ||||
| 		return await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(instance, new LaunchInstanceMessage(instanceGuid)); | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid instanceGuid, MinecraftStopStrategy stopStrategy) { | ||||
| 		var instance = GetInstance(instanceGuid); | ||||
| 		if (instance == null) { | ||||
| 			return InstanceActionResult.General<StopInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); | ||||
| 		var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(instanceGuid, new StopInstanceMessage(instanceGuid, stopStrategy)); | ||||
| 		if (result.Is(StopInstanceResult.StopInitiated)) { | ||||
| 			await SetInstanceShouldLaunchAutomatically(instanceGuid, false); | ||||
| 		} | ||||
|  | ||||
| 		await SetInstanceShouldLaunchAutomatically(instanceGuid, false); | ||||
|  | ||||
| 		return await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(instance, new StopInstanceMessage(instanceGuid, stopStrategy)); | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	private async Task SetInstanceShouldLaunchAutomatically(Guid instanceGuid, bool shouldLaunchAutomatically) { | ||||
| 		instances.ByGuid.TryReplace(instanceGuid, instance => instance with { | ||||
| 			Configuration = instance.Configuration with { LaunchAutomatically = shouldLaunchAutomatically } | ||||
| 		}); | ||||
| 		await modifyInstancesSemaphore.WaitAsync(cancellationToken); | ||||
| 		try { | ||||
| 			instances.ByGuid.TryReplace(instanceGuid, instance => instance with { | ||||
| 				Configuration = instance.Configuration with { LaunchAutomatically = shouldLaunchAutomatically } | ||||
| 			}); | ||||
|  | ||||
| 		using var scope = databaseProvider.CreateScope(); | ||||
| 		var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken); | ||||
| 		if (entity != null) { | ||||
| 			entity.LaunchAutomatically = shouldLaunchAutomatically; | ||||
| 			await scope.Ctx.SaveChangesAsync(cancellationToken); | ||||
| 			using var scope = databaseProvider.CreateScope(); | ||||
| 			var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken); | ||||
| 			if (entity != null) { | ||||
| 				entity.LaunchAutomatically = shouldLaunchAutomatically; | ||||
| 				await scope.Ctx.SaveChangesAsync(cancellationToken); | ||||
| 			} | ||||
| 		} finally { | ||||
| 			modifyInstancesSemaphore.Release(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { | ||||
| 		var instance = GetInstance(instanceGuid); | ||||
| 		if (instance == null) { | ||||
| 			return InstanceActionResult.General<SendCommandToInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); | ||||
| 		} | ||||
|  | ||||
| 		return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instance, new SendCommandToInstanceMessage(instanceGuid, command)); | ||||
| 		return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command)); | ||||
| 	} | ||||
|  | ||||
| 	internal ImmutableArray<InstanceConfiguration> GetInstanceConfigurationsForAgent(Guid agentGuid) { | ||||
|   | ||||
| @@ -13,7 +13,7 @@ | ||||
|       <Column Width=" 90px; 19%" Class="text-end">Instances</Column> | ||||
|       <Column Width="145px; 21%" Class="text-end">Memory</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="215px" Class="text-end">Last Ping</Column> | ||||
|     </tr> | ||||
|   | ||||
| @@ -1,304 +1,5 @@ | ||||
| @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)] | ||||
| @inject INavigation Nav | ||||
| @inject MinecraftVersions MinecraftVersions | ||||
| @inject AgentManager AgentManager | ||||
| @inject AgentJavaRuntimesManager AgentJavaRuntimesManager | ||||
| @inject InstanceManager InstanceManager | ||||
| @inject AuditLog AuditLog | ||||
|  | ||||
| <h1>New Instance</h1> | ||||
|  | ||||
| <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)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
| <InstanceAddOrEditForm EditedInstanceConfiguration="null" /> | ||||
|   | ||||
| @@ -22,6 +22,9 @@ else { | ||||
|       <span><!-- extra spacing --></span> | ||||
|     </PermissionView> | ||||
|     <InstanceStatusText Status="Instance.Status" /> | ||||
|     <PermissionView Permission="Permission.CreateInstances"> | ||||
|       <a href="instances/@InstanceGuid/edit" class="btn btn-warning ms-auto">Edit Configuration</a> | ||||
|     </PermissionView> | ||||
|   </div> | ||||
|   @if (lastError != null) { | ||||
|     <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> | ||||
|       <Column Width="200px; 28%">Agent</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">Rcon Port</Column> | ||||
|       <Column Width=" 85px;  8%" Class="text-end">Memory</Column> | ||||
|       <Column Width="315px">Identifier</Column> | ||||
|       <Column Width=" 90px;  8%" Class="text-end">Memory</Column> | ||||
|       <Column Width="320px">Identifier</Column> | ||||
|       <Column Width="200px;  9%">Status</Column> | ||||
|       <Column Width=" 75px">Actions</Column> | ||||
|     </tr> | ||||
|   | ||||
| @@ -19,7 +19,7 @@ | ||||
|     <table class="table align-middle"> | ||||
|       <thead> | ||||
|         <tr> | ||||
|           <Column Width="315px">Identifier</Column> | ||||
|           <Column Width="320px">Identifier</Column> | ||||
|           <Column Width="125px; 40%">Username</Column> | ||||
|           <Column Width="125px; 60%">Roles</Column> | ||||
|           @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; | ||||
| } | ||||
|  | ||||
| .btn { | ||||
|   text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.25); | ||||
| } | ||||
|  | ||||
| .table { | ||||
|   margin-top: 0.5rem; | ||||
| } | ||||
| @@ -61,8 +65,13 @@ code { | ||||
|   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 { | ||||
|   /* centering fix does not work in Chrome */ | ||||
|   background: linear-gradient(to right, #dfd7ca 0%, #dfd7ca var(--range-split), #bf8282 var(--range-split), #bf8282 100%); | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user