mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2024-11-25 16:42:54 +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%);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user