Re-implemented server state machine. Cleaned up code

TODO: Handle trigger errors
This commit is contained in:
2025-02-12 23:02:00 +01:00
parent 4088bfaef5
commit f45699f300
44 changed files with 913 additions and 831 deletions

3
.gitignore vendored
View File

@@ -429,3 +429,6 @@ core.min.css
# Build script for nuget packages # Build script for nuget packages
finalPackages/ finalPackages/
nupkgs/ nupkgs/
# Local daemon tests
**/data/volumes/**

View File

@@ -1,20 +1,21 @@
using MoonlightServers.DaemonShared.Enums; using MoonlightServers.DaemonShared.Enums;
using MoonlightServers.Shared.Enums; using MoonlightServers.Shared.Enums;
using ServerState = MoonlightServers.Shared.Enums.ServerState;
namespace MoonlightServers.ApiServer.Extensions; namespace MoonlightServers.ApiServer.Extensions;
public static class ServerStateExtensions public static class ServerStateExtensions
{ {
public static ServerPowerState ToServerPowerState(this ServerState state) public static ServerState ToServerPowerState(this DaemonShared.Enums.ServerState state)
{ {
return state switch return state switch
{ {
ServerState.Installing => ServerPowerState.Installing, DaemonShared.Enums.ServerState.Installing => ServerState.Installing,
ServerState.Stopping => ServerPowerState.Stopping, DaemonShared.Enums.ServerState.Stopping => ServerState.Stopping,
ServerState.Online => ServerPowerState.Online, DaemonShared.Enums.ServerState.Online => ServerState.Online,
ServerState.Starting => ServerPowerState.Starting, DaemonShared.Enums.ServerState.Starting => ServerState.Starting,
ServerState.Offline => ServerPowerState.Offline, DaemonShared.Enums.ServerState.Offline => ServerState.Offline,
_ => ServerPowerState.Offline _ => ServerState.Offline
}; };
} }
} }

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoonCore.Extended.PermFilter; using MoonCore.Extended.PermFilter;
@@ -28,7 +29,7 @@ public class ServersController : Controller
} }
[HttpGet] [HttpGet]
[RequirePermission("meta.authenticated")] [Authorize]
public async Task<PagedData<ServerDetailResponse>> GetAll([FromQuery] int page, [FromQuery] int pageSize) public async Task<PagedData<ServerDetailResponse>> GetAll([FromQuery] int page, [FromQuery] int pageSize)
{ {
var userIdClaim = User.Claims.First(x => x.Type == "userId"); var userIdClaim = User.Claims.First(x => x.Type == "userId");
@@ -69,7 +70,7 @@ public class ServersController : Controller
} }
[HttpGet("{serverId:int}")] [HttpGet("{serverId:int}")]
[RequirePermission("meta.authenticated")] [Authorize]
public async Task<ServerDetailResponse> Get([FromRoute] int serverId) public async Task<ServerDetailResponse> Get([FromRoute] int serverId)
{ {
var server = await GetServerWithPermCheck( var server = await GetServerWithPermCheck(
@@ -96,7 +97,7 @@ public class ServersController : Controller
} }
[HttpGet("{serverId:int}/status")] [HttpGet("{serverId:int}/status")]
[RequirePermission("meta.authenticated")] [Authorize]
public async Task<ServerStatusResponse> GetStatus([FromRoute] int serverId) public async Task<ServerStatusResponse> GetStatus([FromRoute] int serverId)
{ {
var server = await GetServerWithPermCheck(serverId); var server = await GetServerWithPermCheck(serverId);
@@ -111,7 +112,7 @@ public class ServersController : Controller
return new ServerStatusResponse() return new ServerStatusResponse()
{ {
PowerState = data.State.ToServerPowerState() State = data.State.ToServerPowerState()
}; };
} }
catch (HttpRequestException e) catch (HttpRequestException e)
@@ -120,9 +121,9 @@ public class ServersController : Controller
} }
} }
[HttpGet("{serverId:int}/console")] [HttpGet("{serverId:int}/ws")]
[RequirePermission("meta.authenticated")] [Authorize]
public async Task<ServerConsoleResponse> GetConsole([FromRoute] int serverId) public async Task<ServerWebSocketResponse> GetWebSocket([FromRoute] int serverId)
{ {
var server = await GetServerWithPermCheck(serverId); var server = await GetServerWithPermCheck(serverId);
@@ -130,7 +131,7 @@ public class ServersController : Controller
var accessToken = NodeService.CreateAccessToken(server.Node, parameters => var accessToken = NodeService.CreateAccessToken(server.Node, parameters =>
{ {
parameters.Add("type", "console"); parameters.Add("type", "websocket");
parameters.Add("serverId", server.Id); parameters.Add("serverId", server.Id);
}, TimeSpan.FromMinutes(10)); }, TimeSpan.FromMinutes(10));
@@ -141,9 +142,9 @@ public class ServersController : Controller
else else
url += "http://"; url += "http://";
url += $"{server.Node.Fqdn}:{server.Node.HttpPort}/api/servers/console"; url += $"{server.Node.Fqdn}:{server.Node.HttpPort}/api/servers/ws";
return new ServerConsoleResponse() return new ServerWebSocketResponse()
{ {
Target = url, Target = url,
AccessToken = accessToken AccessToken = accessToken
@@ -151,7 +152,7 @@ public class ServersController : Controller
} }
[HttpGet("{serverId:int}/logs")] [HttpGet("{serverId:int}/logs")]
[RequirePermission("meta.authenticated")] [Authorize]
public async Task<ServerLogsResponse> GetLogs([FromRoute] int serverId) public async Task<ServerLogsResponse> GetLogs([FromRoute] int serverId)
{ {
var server = await GetServerWithPermCheck(serverId); var server = await GetServerWithPermCheck(serverId);
@@ -175,6 +176,42 @@ public class ServersController : Controller
} }
} }
[HttpPost("{serverId:int}/start")]
[Authorize]
public async Task Start([FromRoute] int serverId)
{
var server = await GetServerWithPermCheck(serverId);
using var apiClient = await NodeService.CreateApiClient(server.Node);
try
{
await apiClient.Post($"api/servers/{server.Id}/start");
}
catch (HttpRequestException e)
{
throw new HttpApiException("Unable to access the node the server is running on", 502);
}
}
[HttpPost("{serverId:int}/stop")]
[Authorize]
public async Task Stop([FromRoute] int serverId)
{
var server = await GetServerWithPermCheck(serverId);
using var apiClient = await NodeService.CreateApiClient(server.Node);
try
{
await apiClient.Post($"api/servers/{server.Id}/stop");
}
catch (HttpRequestException e)
{
throw new HttpApiException("Unable to access the node the server is running on", 502);
}
}
private async Task<Server> GetServerWithPermCheck(int serverId, private async Task<Server> GetServerWithPermCheck(int serverId,
Func<IQueryable<Server>, IQueryable<Server>>? queryModifier = null) Func<IQueryable<Server>, IQueryable<Server>>? queryModifier = null)
{ {

View File

@@ -372,11 +372,16 @@ public class StarImportExportService
foreach (var pConfigFind in pConfig.Value.GetProperty("find").EnumerateObject()) foreach (var pConfigFind in pConfig.Value.GetProperty("find").EnumerateObject())
{ {
pc.Entries.Add(new ParseConfiguration.ParseConfigurationEntry() var entry = new ParseConfiguration.ParseConfigurationEntry()
{ {
Key = pConfigFind.Name, Key = pConfigFind.Name,
Value = pConfigFind.Value.GetString() ?? "Parse error" Value = pConfigFind.Value.GetString() ?? "Parse error"
}); };
// Fix up special variables
entry.Value = entry.Value.Replace("server.allocations.default.port", "SERVER_PORT");
pc.Entries.Add(entry);
} }
resultPcs.Add(pc); resultPcs.Add(pc);

View File

@@ -1,17 +1,16 @@
using System.Text; using System.Text;
using Docker.DotNet; using Docker.DotNet;
using Docker.DotNet.Models; using Docker.DotNet.Models;
using MoonlightServers.Daemon.Models;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions; namespace MoonlightServers.Daemon.Abstractions;
public static class ServerConsoleExtensions public partial class Server
{ {
public static async Task Attach(this Server server) private async Task AttachConsole(string containerId)
{ {
var dockerClient = server.ServiceProvider.GetRequiredService<DockerClient>(); var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
var stream = await dockerClient.Containers.AttachContainerAsync(server.ContainerId, true, var stream = await dockerClient.Containers.AttachContainerAsync(containerId, true,
new ContainerAttachParameters() new ContainerAttachParameters()
{ {
Stderr = true, Stderr = true,
@@ -19,12 +18,13 @@ public static class ServerConsoleExtensions
Stdout = true, Stdout = true,
Stream = true Stream = true
}, },
server.Cancellation.Token Cancellation.Token
); );
// Reading
Task.Run(async () => Task.Run(async () =>
{ {
while (!server.Cancellation.Token.IsCancellationRequested) while (!Cancellation.Token.IsCancellationRequested)
{ {
try try
{ {
@@ -34,7 +34,7 @@ public static class ServerConsoleExtensions
buffer, buffer,
0, 0,
buffer.Length, buffer.Length,
server.Cancellation.Token Cancellation.Token
); );
if (readResult.EOF) if (readResult.EOF)
@@ -45,7 +45,7 @@ public static class ServerConsoleExtensions
buffer = new byte[buffer.Length]; buffer = new byte[buffer.Length];
var decodedText = Encoding.UTF8.GetString(resizedBuffer); var decodedText = Encoding.UTF8.GetString(resizedBuffer);
await server.Console.WriteToOutput(decodedText); await Console.WriteToOutput(decodedText);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -57,9 +57,24 @@ public static class ServerConsoleExtensions
} }
catch (Exception e) catch (Exception e)
{ {
server.Logger.LogWarning("An unhandled error occured while reading from container stream: {e}", e); Logger.LogWarning("An unhandled error occured while reading from container stream: {e}", e);
} }
} }
}); });
// Writing
Console.OnInput += async content =>
{
var contentBuffer = Encoding.UTF8.GetBytes(content);
await stream.WriteAsync(contentBuffer, 0, contentBuffer.Length, Cancellation.Token);
};
} }
private async Task LogToConsole(string message)
{
await Console.WriteToOutput($"\x1b[38;5;16;48;5;135m\x1b[39m\x1b[1m Moonlight \x1b[0m {message}\n\r");
}
public Task<string[]> GetConsoleMessages()
=> Task.FromResult(Console.Messages);
} }

View File

@@ -0,0 +1,37 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
private async Task Create()
{
var dockerImageService = ServiceProvider.GetRequiredService<DockerImageService>();
// We call an external service for that, as we want to have a central management point of images
// for analytics and automatic deletion
await dockerImageService.Ensure(Configuration.DockerImage, async message => { await LogToConsole(message); });
var hostPath = await EnsureRuntimeVolume();
await LogToConsole("Creating container");
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
var parameters = Configuration.ToRuntimeCreateParameters(
hostPath: hostPath,
containerName: RuntimeContainerName
);
var container = await dockerClient.Containers.CreateContainerAsync(parameters);
RuntimeContainerId = container.ID;
}
private async Task ReCreate()
{
await Destroy();
await Create();
}
}

View File

@@ -0,0 +1,50 @@
using Docker.DotNet;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
private async Task Destroy()
{
// Note: This only destroys the container, it doesn't delete any data
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
try
{
var container = await dockerClient.Containers.InspectContainerAsync(
RuntimeContainerName
);
if (container.State.Running)
{
// Stop container when running
await LogToConsole("Stopping container");
await dockerClient.Containers.StopContainerAsync(container.ID, new()
{
WaitBeforeKillSeconds = 30 // TODO: Config
});
}
await LogToConsole("Removing container");
await dockerClient.Containers.RemoveContainerAsync(container.ID, new());
}
catch (DockerContainerNotFoundException){}
// Canceling server tasks & listeners
await CancelTasks();
// and recreating cancellation token
Cancellation = new();
}
public async Task CancelTasks()
{
// Note: This will keep the docker container running, it will just cancel the server cancellation token
if (!Cancellation.IsCancellationRequested)
await Cancellation.CancelAsync();
}
}

View File

@@ -0,0 +1,166 @@
using System.Text.RegularExpressions;
using Docker.DotNet.Models;
using MoonlightServers.Daemon.Enums;
using Stateless;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
// We are expecting a list of running containers, as we don't wont to inspect every possible container just to check if it exists.
// If none are provided, we skip the checks. Use this overload if you are creating a new server which didn't exist before
public async Task Initialize(IList<ContainerListResponse>? runningContainers = null)
{
if (runningContainers != null)
{
var reAttachSuccessful = await ReAttach(runningContainers);
// If we weren't able to reattach with the current running containers, we initialize the
// state machine as offline
if(!reAttachSuccessful)
await InitializeStateMachine(ServerState.Offline);
}
else
await InitializeStateMachine(ServerState.Offline);
// And at last we initialize all events, so we can react to certain state changes and outputs.
// We need to do this regardless if the server was reattached or not, as it hasn't been initialized yet
await InitializeEvents();
}
private Task InitializeStateMachine(ServerState initialState)
{
StateMachine = new StateMachine<ServerState, ServerTrigger>(initialState);
// Setup transitions
StateMachine.Configure(ServerState.Offline)
.Permit(ServerTrigger.Start, ServerState.Starting)
.Permit(ServerTrigger.Reinstall, ServerState.Installing);
StateMachine.Configure(ServerState.Starting)
.Permit(ServerTrigger.NotifyContainerDied, ServerState.Offline)
.Permit(ServerTrigger.NotifyOnline, ServerState.Online)
.Permit(ServerTrigger.Stop, ServerState.Stopping)
.OnEntryAsync(InternalStart);
StateMachine.Configure(ServerState.Online)
.Permit(ServerTrigger.NotifyContainerDied, ServerState.Offline)
.Permit(ServerTrigger.Stop, ServerState.Stopping);
StateMachine.Configure(ServerState.Stopping)
.Permit(ServerTrigger.NotifyContainerDied, ServerState.Offline)
.Permit(ServerTrigger.Kill, ServerState.Offline)
.OnEntryAsync(InternalStop);
StateMachine.Configure(ServerState.Installing)
.Permit(ServerTrigger.NotifyInstallContainerDied, ServerState.Offline);
return Task.CompletedTask;
}
private Task InitializeEvents()
{
Console.OnOutput += async content =>
{
if (StateMachine.State == ServerState.Starting)
{
if (Regex.Matches(content, Configuration.OnlineDetection).Count > 0)
await StateMachine.FireAsync(ServerTrigger.NotifyOnline);
}
};
StateMachine.OnTransitioned(transition =>
{
Logger.LogInformation(
"{source} => {destination} ({trigger})",
transition.Source,
transition.Destination,
transition.Trigger
);
});
StateMachine.OnTransitionCompleted(transition =>
{
Logger.LogInformation("State: {state}", transition.Destination);
});
// Proxy the events so outside subscribes can react to it
StateMachine.OnTransitionCompletedAsync(async transition =>
{
if (OnStateChanged != null)
{
await OnStateChanged(transition.Destination);
}
});
Console.OnOutput += (async message =>
{
if (OnConsoleOutput != null)
{
await OnConsoleOutput(message);
}
});
return Task.CompletedTask;
}
#region Reattaching & reattach strategies
private async Task<bool> ReAttach(IList<ContainerListResponse> runningContainers)
{
// Docker container names are starting with a / when returned in the docker container list api endpoint,
// so we trim it from the name when searching
var existingRuntimeContainer = runningContainers.FirstOrDefault(
x => x.Names.Any(y => y.TrimStart('/') == RuntimeContainerName)
);
if (existingRuntimeContainer != null)
{
await ReAttachToRuntime(existingRuntimeContainer);
return true;
}
var existingInstallContainer = runningContainers.FirstOrDefault(
x => x.Names.Any(y => y.TrimStart('/') == InstallationContainerName)
);
if (existingInstallContainer != null)
{
await ReAttachToInstallation(existingInstallContainer);
return true;
}
return false;
}
private async Task ReAttachToRuntime(ContainerListResponse runtimeContainer)
{
if (runtimeContainer.State == "running")
{
RuntimeContainerId = runtimeContainer.ID;
await InitializeStateMachine(ServerState.Online);
await AttachConsole(runtimeContainer.ID);
}
else
await InitializeStateMachine(ServerState.Offline);
}
private async Task ReAttachToInstallation(ContainerListResponse installationContainer)
{
if (installationContainer.State == "running")
{
InstallationContainerId = installationContainer.ID;
await InitializeStateMachine(ServerState.Installing);
await AttachConsole(installationContainer.ID);
}
else
await InitializeStateMachine(ServerState.Offline);
}
#endregion
}

View File

@@ -0,0 +1,8 @@
using MoonlightServers.Daemon.Enums;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task NotifyContainerDied() => await StateMachine.FireAsync(ServerTrigger.NotifyContainerDied);
}

View File

@@ -0,0 +1,23 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Enums;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task Start() => await StateMachine.FireAsync(ServerTrigger.Start);
private async Task InternalStart()
{
await ReCreate();
await LogToConsole("Starting container");
// We can disable the null check for the runtime container id, as we set it by calling ReCreate();
await AttachConsole(RuntimeContainerId!);
// Start container
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
await dockerClient.Containers.StartContainerAsync(RuntimeContainerId, new());
}
}

View File

@@ -0,0 +1,13 @@
using MoonlightServers.Daemon.Enums;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task Stop() => await StateMachine.FireAsync(ServerTrigger.Stop);
private async Task InternalStop()
{
await Console.WriteToInput($"{Configuration.StopCommand}\n\r");
}
}

View File

@@ -0,0 +1,55 @@
using MoonCore.Helpers;
using MoonlightServers.Daemon.Configuration;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
private async Task<string> EnsureRuntimeVolume()
{
var appConfiguration = ServiceProvider.GetRequiredService<AppConfiguration>();
var hostPath = PathBuilder.Dir(
appConfiguration.Storage.Volumes,
Configuration.Id.ToString()
);
await LogToConsole("Creating storage");
// TODO: Virtual disk
// Create volume if missing
if (!Directory.Exists(hostPath))
Directory.CreateDirectory(hostPath);
if (hostPath.StartsWith("/"))
return hostPath;
else
return PathBuilder.JoinPaths(Directory.GetCurrentDirectory(), hostPath);
return hostPath;
}
private async Task<string> EnsureInstallationVolume()
{
var appConfiguration = ServiceProvider.GetRequiredService<AppConfiguration>();
var hostPath = PathBuilder.Dir(
appConfiguration.Storage.Volumes,
Configuration.Id.ToString()
);
await LogToConsole("Creating storage");
// Create volume if missing
if (!Directory.Exists(hostPath))
Directory.CreateDirectory(hostPath);
if (hostPath.StartsWith("/"))
return hostPath;
else
return PathBuilder.JoinPaths(Directory.GetCurrentDirectory(), hostPath);
return hostPath;
}
}

View File

@@ -0,0 +1,52 @@
using Docker.DotNet.Models;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Models;
using MoonlightServers.Daemon.Models.Cache;
using Stateless;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
// Exposed configuration/state values
public int Id => Configuration.Id;
public ServerState State => StateMachine.State;
// Exposed container names and ids
public string RuntimeContainerName { get; private set; }
public string? RuntimeContainerId { get; private set; }
public string InstallationContainerName { get; private set; }
public string? InstallationContainerId { get; private set; }
// Events
public event Func<ServerState, Task> OnStateChanged;
public event Func<string, Task> OnConsoleOutput;
// Private stuff
private readonly ILogger Logger;
private readonly IServiceProvider ServiceProvider;
private readonly ServerConsole Console;
private StateMachine<ServerState, ServerTrigger> StateMachine;
private ServerConfiguration Configuration;
private CancellationTokenSource Cancellation;
public Server(
ILogger logger,
IServiceProvider serviceProvider,
ServerConfiguration configuration
)
{
Logger = logger;
ServiceProvider = serviceProvider;
Configuration = configuration;
Console = new();
Cancellation = new();
RuntimeContainerName = $"moonlight-runtime-{Configuration.Id}";
InstallationContainerName = $"moonlight-install-{Configuration.Id}";
}
}

View File

@@ -0,0 +1,10 @@
namespace MoonlightServers.Daemon.Enums;
public enum ServerState
{
Offline = 0,
Starting = 1,
Online = 2,
Stopping = 3,
Installing = 4
}

View File

@@ -0,0 +1,13 @@
namespace MoonlightServers.Daemon.Enums;
public enum ServerTrigger
{
Start = 0,
Stop = 1,
Restart = 2,
Kill = 3,
Reinstall = 4,
NotifyOnline = 5,
NotifyContainerDied = 6,
NotifyInstallContainerDied = 7
}

View File

@@ -1,15 +1,15 @@
using Docker.DotNet.Models; using Docker.DotNet.Models;
using Mono.Unix.Native; using Mono.Unix.Native;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonlightServers.Daemon.Models; using MoonlightServers.Daemon.Models.Cache;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions; namespace MoonlightServers.Daemon.Extensions;
public static class ServerConfigExtensions public static class ServerConfigurationExtensions
{ {
public static CreateContainerParameters GetRuntimeContainerParameters(this Server server) public static CreateContainerParameters ToRuntimeCreateParameters(this ServerConfiguration configuration, string hostPath, string containerName)
{ {
var parameters = server.GetSharedContainerParameters(); var parameters = configuration.ToSharedCreateParameters();
#region Security #region Security
@@ -29,20 +29,20 @@ public static class ServerConfigExtensions
#region Name #region Name
parameters.Name = server.RuntimeContainerName; parameters.Name = containerName;
parameters.Hostname = server.RuntimeContainerName; parameters.Hostname = containerName;
#endregion #endregion
#region Docker Image #region Docker Image
parameters.Image = server.Configuration.DockerImage; parameters.Image = configuration.DockerImage;
#endregion #endregion
#region Environment #region Environment
parameters.Env = server.ConstructEnv() parameters.Env = configuration.ToEnvironmentVariables()
.Select(x => $"{x.Key}={x.Value}") .Select(x => $"{x.Key}={x.Value}")
.ToList(); .ToList();
@@ -56,7 +56,7 @@ public static class ServerConfigExtensions
#region User #region User
var userId = Syscall.getuid(); var userId = Syscall.getuid(); // TODO: Extract to external service?
if (userId == 0) if (userId == 0)
{ {
@@ -78,7 +78,7 @@ public static class ServerConfigExtensions
parameters.HostConfig.Mounts.Add(new() parameters.HostConfig.Mounts.Add(new()
{ {
Source = server.RuntimeVolumePath, Source = hostPath,
Target = "/home/container", Target = "/home/container",
ReadOnly = false, ReadOnly = false,
Type = "bind" Type = "bind"
@@ -93,7 +93,7 @@ public static class ServerConfigExtensions
parameters.ExposedPorts = new Dictionary<string, EmptyStruct>(); parameters.ExposedPorts = new Dictionary<string, EmptyStruct>();
parameters.HostConfig.PortBindings = new Dictionary<string, IList<PortBinding>>(); parameters.HostConfig.PortBindings = new Dictionary<string, IList<PortBinding>>();
foreach (var allocation in server.Configuration.Allocations) foreach (var allocation in configuration.Allocations)
{ {
parameters.ExposedPorts.Add($"{allocation.Port}/tcp", new()); parameters.ExposedPorts.Add($"{allocation.Port}/tcp", new());
parameters.ExposedPorts.Add($"{allocation.Port}/udp", new()); parameters.ExposedPorts.Add($"{allocation.Port}/udp", new());
@@ -123,7 +123,7 @@ public static class ServerConfigExtensions
return parameters; return parameters;
} }
public static CreateContainerParameters GetSharedContainerParameters(this Server server) private static CreateContainerParameters ToSharedCreateParameters(this ServerConfiguration configuration)
{ {
var parameters = new CreateContainerParameters() var parameters = new CreateContainerParameters()
{ {
@@ -142,7 +142,7 @@ public static class ServerConfigExtensions
#region CPU #region CPU
parameters.HostConfig.CPUQuota = server.Configuration.Cpu * 1000; parameters.HostConfig.CPUQuota = configuration.Cpu * 1000;
parameters.HostConfig.CPUPeriod = 100000; parameters.HostConfig.CPUPeriod = 100000;
parameters.HostConfig.CPUShares = 1024; parameters.HostConfig.CPUShares = 1024;
@@ -150,7 +150,7 @@ public static class ServerConfigExtensions
#region Memory & Swap #region Memory & Swap
var memoryLimit = server.Configuration.Memory; var memoryLimit = configuration.Memory;
// The overhead multiplier gives the container a little bit more memory to prevent crashes // The overhead multiplier gives the container a little bit more memory to prevent crashes
var memoryOverhead = memoryLimit + (memoryLimit * 0.05f); // TODO: Config var memoryOverhead = memoryLimit + (memoryLimit * 0.05f); // TODO: Config
@@ -183,6 +183,8 @@ public static class ServerConfigExtensions
#region DNS #region DNS
// TODO: Read hosts dns settings?
parameters.HostConfig.DNS = /*config.Docker.DnsServers.Any() ? config.Docker.DnsServers :*/ new List<string>() parameters.HostConfig.DNS = /*config.Docker.DnsServers.Any() ? config.Docker.DnsServers :*/ new List<string>()
{ {
"1.1.1.1", "1.1.1.1",
@@ -215,27 +217,25 @@ public static class ServerConfigExtensions
parameters.Labels = new Dictionary<string, string>(); parameters.Labels = new Dictionary<string, string>();
parameters.Labels.Add("Software", "Moonlight-Panel"); parameters.Labels.Add("Software", "Moonlight-Panel");
parameters.Labels.Add("ServerId", server.Configuration.Id.ToString()); parameters.Labels.Add("ServerId", configuration.Id.ToString());
#endregion #endregion
return parameters; return parameters;
} }
public static Dictionary<string, string> ConstructEnv(this Server server) public static Dictionary<string, string> ToEnvironmentVariables(this ServerConfiguration configuration)
{ {
var config = server.Configuration;
var result = new Dictionary<string, string> var result = new Dictionary<string, string>
{ {
//TODO: Add timezone, add server ip //TODO: Add timezone, add server ip
{ "STARTUP", config.StartupCommand }, { "STARTUP", configuration.StartupCommand },
{ "SERVER_MEMORY", config.Memory.ToString() } { "SERVER_MEMORY", configuration.Memory.ToString() }
}; };
if (config.Allocations.Length > 0) if (configuration.Allocations.Length > 0)
{ {
var mainAllocation = config.Allocations.First(); var mainAllocation = configuration.Allocations.First();
result.Add("SERVER_IP", mainAllocation.IpAddress); result.Add("SERVER_IP", mainAllocation.IpAddress);
result.Add("SERVER_PORT", mainAllocation.Port.ToString()); result.Add("SERVER_PORT", mainAllocation.Port.ToString());
@@ -243,14 +243,14 @@ public static class ServerConfigExtensions
// Handle allocation variables // Handle allocation variables
var i = 1; var i = 1;
foreach (var allocation in config.Allocations) foreach (var allocation in configuration.Allocations)
{ {
result.Add($"ML_PORT_{i}", allocation.Port.ToString()); result.Add($"ML_PORT_{i}", allocation.Port.ToString());
i++; i++;
} }
// Copy variables as env vars // Copy variables as env vars
foreach (var variable in config.Variables) foreach (var variable in configuration.Variables)
result.Add(variable.Key, variable.Value); result.Add(variable.Key, variable.Value);
return result; return result;

View File

@@ -1,36 +0,0 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions;
public static class ServerCreateExtensions
{
public static async Task Create(this Server server)
{
var dockerClient = server.ServiceProvider.GetRequiredService<DockerClient>();
// Ensure image is pulled
await server.EnsureDockerImage();
// Ensure runtime storage is created
await server.EnsureRuntimeStorage();
// Creating container
await server.NotifyTask(ServerTask.CreatingContainer);
var parameters = server.GetRuntimeContainerParameters();
var container = await dockerClient.Containers.CreateContainerAsync(parameters);
server.ContainerId = container.ID;
// Attach console
await server.Attach();
}
public static async Task ReCreate(this Server server)
{
await server.Destroy();
await server.Create();
}
}

View File

@@ -1,44 +0,0 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions;
public static class ServerDestroyExtensions
{
public static async Task Destroy(this Server server)
{
// Note: This only destroys the container, it doesn't delete any data
var dockerClient = server.ServiceProvider.GetRequiredService<DockerClient>();
try
{
var container = await dockerClient.Containers.InspectContainerAsync(
server.RuntimeContainerName
);
if (container.State.Running)
{
// Stop container when running
await server.NotifyTask(ServerTask.StoppingContainer);
await dockerClient.Containers.StopContainerAsync(container.ID, new()
{
WaitBeforeKillSeconds = 30 // TODO: Config
});
}
await server.NotifyTask(ServerTask.RemovingContainer);
await dockerClient.Containers.RemoveContainerAsync(container.ID, new());
}
catch (DockerContainerNotFoundException){}
// Canceling server sub-tasks and recreating cancellation token
if (!server.Cancellation.IsCancellationRequested)
await server.Cancellation.CancelAsync();
server.Cancellation = new();
}
}

View File

@@ -1,41 +0,0 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions;
public static class ServerImageExtensions
{
public static async Task EnsureDockerImage(this Server server)
{
await server.NotifyTask(ServerTask.PullingDockerImage);
var dockerClient = server.ServiceProvider.GetRequiredService<DockerClient>();
await dockerClient.Images.CreateImageAsync(new()
{
FromImage = server.Configuration.DockerImage
},
new AuthConfig(),
new Progress<JSONMessage>(async message =>
{
if (message.Progress == null)
return;
var percentage = message.Progress.Total > 0
? Math.Round((float)message.Progress.Current / message.Progress.Total * 100f, 2)
: 0d;
server.Logger.LogInformation(
"Docker Image: [{id}] {status} - {percent}",
message.ID,
message.Status,
percentage
);
//await UpdateProgress(server, serviceProvider, percentage);
})
);
}
}

View File

@@ -1,13 +0,0 @@
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions;
public static class ServerMetaExtensions
{
public static async Task NotifyTask(this Server server, ServerTask task)
{
server.Logger.LogInformation("Task: {task}", task);
await server.InvokeTaskAdded(task.ToString());
}
}

View File

@@ -1,18 +0,0 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions;
public static class ServerStartExtensions
{
public static async Task StateMachineHandler_Start(this Server server)
{
await server.ReCreate();
await server.NotifyTask(ServerTask.StartingContainer);
var dockerClient = server.ServiceProvider.GetRequiredService<DockerClient>();
await dockerClient.Containers.StartContainerAsync(server.ContainerId, new());
}
}

View File

@@ -1,20 +0,0 @@
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Extensions.ServerExtensions;
public static class ServerStorageExtensions
{
public static async Task EnsureRuntimeStorage(this Server server)
{
// TODO: Add virtual disk
await server.NotifyTask(ServerTask.CreatingStorage);
// Create volume if missing
if (!Directory.Exists(server.RuntimeVolumePath))
Directory.CreateDirectory(server.RuntimeVolumePath);
// TODO: Chown
//Syscall.chown()
}
}

View File

@@ -1,119 +0,0 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using MoonCore.Helpers;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Helpers;
public class ServerActionHelper
{
public static async Task Start(Server server, IServiceProvider serviceProvider)
{
await EnsureStorage(server, serviceProvider);
await EnsureDockerImage(server, serviceProvider);
await CreateRuntimeContainer(server, serviceProvider);
await StartRuntimeContainer(server, serviceProvider);
}
private static async Task EnsureStorage(Server server, IServiceProvider serviceProvider)
{
await NotifyTask(server, serviceProvider, ServerTask.CreatingStorage);
// Build paths
var configuration = serviceProvider.GetRequiredService<AppConfiguration>();
var volumePath = PathBuilder.Dir(
configuration.Storage.Volumes,
server.Configuration.Id.ToString()
);
// Create volume if missing
if (!Directory.Exists(volumePath))
Directory.CreateDirectory(volumePath);
// TODO: Virtual disk
}
private static async Task EnsureDockerImage(Server server, IServiceProvider serviceProvider)
{
await NotifyTask(server, serviceProvider, ServerTask.PullingDockerImage);
var dockerClient = serviceProvider.GetRequiredService<DockerClient>();
await dockerClient.Images.CreateImageAsync(new()
{
FromImage = server.Configuration.DockerImage
},
new AuthConfig(),
new Progress<JSONMessage>(async message =>
{
//var percentage = (int)(message.Progress.Current / message.Progress.Total);
//await UpdateProgress(server, serviceProvider, percentage);
})
);
}
private static async Task CreateRuntimeContainer(Server server, IServiceProvider serviceProvider)
{
var dockerClient = serviceProvider.GetRequiredService<DockerClient>();
try
{
var existingContainer = await dockerClient.Containers.InspectContainerAsync(
$"moonlight-runtime-{server.Configuration.Id}"
);
await NotifyTask(server, serviceProvider, ServerTask.RemovingContainer);
if (existingContainer.State.Running) // Stop already running container
{
await dockerClient.Containers.StopContainerAsync(existingContainer.ID, new()
{
WaitBeforeKillSeconds = 30 // TODO: Config
});
}
await dockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new());
}
catch (DockerContainerNotFoundException)
{
}
await NotifyTask(server, serviceProvider, ServerTask.CreatingContainer);
// Create a new container
var parameters = new CreateContainerParameters();
ServerConfigurationHelper.ApplyRuntimeOptions(
parameters,
server.Configuration,
serviceProvider.GetRequiredService<AppConfiguration>()
);
var container = await dockerClient.Containers.CreateContainerAsync(parameters);
server.ContainerId = container.ID;
}
private static async Task StartRuntimeContainer(Server server, IServiceProvider serviceProvider)
{
await NotifyTask(server, serviceProvider, ServerTask.StartingContainer);
var dockerClient = serviceProvider.GetRequiredService<DockerClient>();
await dockerClient.Containers.StartContainerAsync(server.ContainerId, new());
}
private static async Task NotifyTask(Server server, IServiceProvider serviceProvider, ServerTask task)
{
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger($"Server {server.Configuration.Id}");
logger.LogInformation("Task: {task}", task);
}
private static async Task UpdateProgress(Server server, IServiceProvider serviceProvider, int progress)
{
}
}

View File

@@ -1,44 +0,0 @@
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Models;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Helpers;
public class ServerConsoleMonitor
{
private readonly Server Server;
private readonly IHubClients Clients;
public ServerConsoleMonitor(Server server, IHubClients clients)
{
Server = server;
Clients = clients;
}
public void Initialize()
{
Server.StateMachine.OnTransitioned += OnPowerStateChanged;
Server.OnTaskAdded += OnTaskNotify;
}
public void Destroy()
{
Server.StateMachine.OnTransitioned -= OnPowerStateChanged;
}
private async Task OnTaskNotify(string task)
{
await Clients.Group($"server-{Server.Configuration.Id}").SendAsync(
"TaskNotify",
task
);
}
private async Task OnPowerStateChanged(ServerState serverState)
{
await Clients.Group($"server-{Server.Configuration.Id}").SendAsync(
"PowerStateChanged",
serverState.ToString()
);
}
}

View File

@@ -1,28 +1,29 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Abstractions;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Http.Hubs; using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Models; using MoonlightServers.Daemon.Models;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Helpers; namespace MoonlightServers.Daemon.Helpers;
public class ServerConsoleConnection public class ServerWebSocketConnection
{ {
private readonly ServerService ServerService; private readonly ServerService ServerService;
private readonly ILogger<ServerConsoleConnection> Logger; private readonly ILogger<ServerWebSocketConnection> Logger;
private readonly AccessTokenHelper AccessTokenHelper; private readonly AccessTokenHelper AccessTokenHelper;
private readonly IHubContext<ServerConsoleHub> HubContext; private readonly IHubContext<ServerWebSocketHub> HubContext;
private int ServerId = -1; private int ServerId = -1;
private Server Server; private Server Server;
private bool IsInitialized = false; private bool IsInitialized = false;
private string ConnectionId; private string ConnectionId;
public ServerConsoleConnection( public ServerWebSocketConnection(
ServerService serverService, ServerService serverService,
ILogger<ServerConsoleConnection> logger, ILogger<ServerWebSocketConnection> logger,
AccessTokenHelper accessTokenHelper, AccessTokenHelper accessTokenHelper,
IHubContext<ServerConsoleHub> hubContext IHubContext<ServerWebSocketHub> hubContext
) )
{ {
ServerService = serverService; ServerService = serverService;
@@ -64,7 +65,7 @@ public class ServerConsoleConnection
// Validate access token type // Validate access token type
var type = accessData["type"].GetString()!; var type = accessData["type"].GetString()!;
if (type != "console") if (type != "websocket")
{ {
Logger.LogDebug("Received invalid access token: Invalid type '{type}'", type); Logger.LogDebug("Received invalid access token: Invalid type '{type}'", type);
@@ -78,7 +79,7 @@ public class ServerConsoleConnection
var serverId = accessData["serverId"].GetInt32(); var serverId = accessData["serverId"].GetInt32();
// Check that the access token isn't or another server // Check that the access token isn't for another server
if (ServerId != -1 && ServerId == serverId) if (ServerId != -1 && ServerId == serverId)
{ {
Logger.LogDebug("Received invalid access token: Server id not valid for this session. Current server id: {serverId}", ServerId); Logger.LogDebug("Received invalid access token: Server id not valid for this session. Current server id: {serverId}", ServerId);
@@ -117,30 +118,26 @@ public class ServerConsoleConnection
IsInitialized = true; IsInitialized = true;
// Setup event handlers // Setup event handlers
Server.StateMachine.OnTransitioned += HandlePowerStateChange; Server.OnConsoleOutput += HandleConsoleOutput;
Server.OnTaskAdded += HandleTaskAdded; Server.OnStateChanged += HandleStateChange;
Server.Console.OnOutput += HandleConsoleOutput;
Logger.LogTrace("Authenticated and initialized server console connection '{id}'", context.ConnectionId); Logger.LogTrace("Authenticated and initialized server console connection '{id}'", context.ConnectionId);
} }
public Task Destroy(HubCallerContext context) public Task Destroy(HubCallerContext context)
{ {
Server.StateMachine.OnTransitioned -= HandlePowerStateChange;
Server.OnTaskAdded -= HandleTaskAdded;
Logger.LogTrace("Destroyed server console connection '{id}'", context.ConnectionId); Logger.LogTrace("Destroyed server console connection '{id}'", context.ConnectionId);
Server.OnConsoleOutput -= HandleConsoleOutput;
Server.OnStateChanged -= HandleStateChange;
return Task.CompletedTask; return Task.CompletedTask;
} }
#region Event Handlers #region Event Handlers
private async Task HandlePowerStateChange(ServerState serverState) private async Task HandleStateChange(ServerState state)
=> await HubContext.Clients.Client(ConnectionId).SendAsync("PowerStateChanged", serverState.ToString()); => await HubContext.Clients.Client(ConnectionId).SendAsync("StateChanged", state.ToString());
private async Task HandleTaskAdded(string task)
=> await HubContext.Clients.Client(ConnectionId).SendAsync("TaskNotify", task);
private async Task HandleConsoleOutput(string line) private async Task HandleConsoleOutput(string line)
=> await HubContext.Clients.Client(ConnectionId).SendAsync("ConsoleOutput", line); => await HubContext.Clients.Client(ConnectionId).SendAsync("ConsoleOutput", line);

View File

@@ -1,74 +0,0 @@
namespace MoonlightServers.Daemon.Helpers;
public class StateMachine<T> where T : struct, Enum
{
private readonly List<StateMachineTransition> Transitions = new();
private readonly object Lock = new();
public T CurrentState { get; private set; }
public event Func<T, Task> OnTransitioned;
public event Action<T, Exception> OnError;
public StateMachine(T initialState)
{
CurrentState = initialState;
}
public void AddTransition(T from, T to, T? onError, Func<Task>? fun)
{
Transitions.Add(new()
{
From = from,
To = to,
OnError = onError,
OnTransitioning = fun
});
}
public void AddTransition(T from, T to, Func<Task> fun) => AddTransition(from, to, null, fun);
public void AddTransition(T from, T to) => AddTransition(from, to, null, null);
public async Task TransitionTo(T to)
{
lock (Lock)
{
var transition = Transitions.FirstOrDefault(x =>
x.From.Equals(CurrentState) &&
x.To.Equals(to)
);
if (transition == null)
throw new InvalidOperationException("Unable to transition to the request state: No transition found");
try
{
if(transition.OnTransitioning != null)
transition.OnTransitioning.Invoke().Wait();
// Successfully executed => update state
CurrentState = transition.To;
}
catch (Exception e)
{
if(OnError != null)
OnError.Invoke(to, e);
if (transition.OnError.HasValue)
CurrentState = transition.OnError.Value;
else
throw new AggregateException("An error occured while transitioning to a state", e);
}
}
if(OnTransitioned != null)
await OnTransitioned.Invoke(CurrentState);
}
public class StateMachineTransition
{
public T From { get; set; }
public T To { get; set; }
public T? OnError { get; set; }
public Func<Task>? OnTransitioning { get; set; }
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Controllers.Servers;
[ApiController]
[Route("api/servers")]
public class ServerPowerController : Controller
{
private readonly ServerService ServerService;
public ServerPowerController(ServerService serverService)
{
ServerService = serverService;
}
[HttpPost("{serverId:int}/start")]
public async Task Start(int serverId, [FromQuery] bool runAsync = true)
{
var server = ServerService.GetServer(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.Start();
}
[HttpPost("{serverId:int}/stop")]
public async Task Stop(int serverId, [FromQuery] bool runAsync = true)
{
var server = ServerService.GetServer(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.Stop();
}
}

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions; using MoonCore.Exceptions;
using MoonlightServers.Daemon.Models;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers; using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers;
using MoonlightServers.DaemonShared.Enums; using MoonlightServers.DaemonShared.Enums;
@@ -28,7 +27,7 @@ public class ServersController : Controller
return new ServerStatusResponse() return new ServerStatusResponse()
{ {
State = server.State State = (ServerState)server.State
}; };
} }
@@ -42,18 +41,7 @@ public class ServersController : Controller
return new ServerLogsResponse() return new ServerLogsResponse()
{ {
Messages = server.Console.Messages Messages = await server.GetConsoleMessages()
}; };
} }
[HttpPost("{serverId:int}/start")]
public async Task Start(int serverId)
{
var server = ServerService.GetServer(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.StateMachine.TransitionTo(ServerState.Starting);
}
} }

View File

@@ -1,29 +1,26 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Models;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Http.Hubs; namespace MoonlightServers.Daemon.Http.Hubs;
public class ServerConsoleHub : Hub public class ServerWebSocketHub : Hub
{ {
private readonly ILogger<ServerConsoleHub> Logger; private readonly ILogger<ServerWebSocketHub> Logger;
private readonly ServerConsoleService ConsoleService; private readonly ServerWebSocketService WebSocketService;
public ServerConsoleHub(ILogger<ServerConsoleHub> logger, ServerConsoleService consoleService) public ServerWebSocketHub(ILogger<ServerWebSocketHub> logger, ServerWebSocketService webSocketService)
{ {
Logger = logger; Logger = logger;
ConsoleService = consoleService; WebSocketService = webSocketService;
} }
#region Connection Handlers #region Connection Handlers
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
=> await ConsoleService.InitializeClient(Context); => await WebSocketService.InitializeClient(Context);
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
=> await ConsoleService.DestroyClient(Context); => await WebSocketService.DestroyClient(Context);
#endregion #endregion
@@ -34,7 +31,7 @@ public class ServerConsoleHub : Hub
{ {
try try
{ {
await ConsoleService.AuthenticateClient(Context, accessToken); await WebSocketService.AuthenticateClient(Context, accessToken);
} }
catch (Exception e) catch (Exception e)
{ {

View File

@@ -1,75 +0,0 @@
using MoonCore.Helpers;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Models;
public class Server
{
public ILogger Logger { get; set; }
public ServerConsole Console { get; set; }
public IServiceProvider ServiceProvider { get; set; }
public ServerState State => StateMachine.CurrentState;
public StateMachine<ServerState> StateMachine { get; set; }
public ServerConfiguration Configuration { get; set; }
public string? ContainerId { get; set; }
public event Func<string, Task> OnTaskAdded;
// This can be used to stop streaming when the server gets destroyed or something
public CancellationTokenSource Cancellation { get; set; }
#region Small helpers
public string RuntimeContainerName => $"moonlight-runtime-{Configuration.Id}";
public string InstallContainerName => $"moonlight-install-{Configuration.Id}";
public string RuntimeVolumePath
{
get
{
var appConfig = ServiceProvider.GetRequiredService<AppConfiguration>();
var localPath = PathBuilder.Dir(
appConfig.Storage.Volumes,
Configuration.Id.ToString()
);
var absolutePath = Path.GetFullPath(localPath);
return absolutePath;
}
}
public string InstallVolumePath
{
get
{
var appConfig = ServiceProvider.GetRequiredService<AppConfiguration>();
var localPath = PathBuilder.Dir(
appConfig.Storage.Install,
Configuration.Id.ToString()
);
var absolutePath = Path.GetFullPath(localPath);
return absolutePath;
}
}
#endregion
#region Event invokers
public async Task InvokeTaskAdded(string task)
{
if(OnTaskAdded == null)
return;
await OnTaskAdded.Invoke(task).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
#endregion
}

View File

@@ -9,9 +9,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Docker.DotNet" Version="3.125.15" /> <PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="MoonCore" Version="1.8.1" /> <PackageReference Include="MoonCore" Version="1.8.2" />
<PackageReference Include="MoonCore.Extended" Version="1.2.4" /> <PackageReference Include="MoonCore.Extended" Version="1.2.7" />
<PackageReference Include="MoonCore.Unix" Version="1.0.0" /> <PackageReference Include="MoonCore.Unix" Version="1.0.0" />
<PackageReference Include="Stateless" Version="5.17.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup> </ItemGroup>
@@ -24,13 +25,19 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<_ContentIncludedByDefault Remove="storage\volumes\11\banned-ips.json" /> <Compile Remove="data\**" />
<_ContentIncludedByDefault Remove="storage\volumes\11\banned-players.json" /> </ItemGroup>
<_ContentIncludedByDefault Remove="storage\volumes\11\ops.json" />
<_ContentIncludedByDefault Remove="storage\volumes\11\plugins\spark\config.json" /> <ItemGroup>
<_ContentIncludedByDefault Remove="storage\volumes\11\usercache.json" /> <EmbeddedResource Remove="data\**" />
<_ContentIncludedByDefault Remove="storage\volumes\11\version_history.json" /> </ItemGroup>
<_ContentIncludedByDefault Remove="storage\volumes\11\whitelist.json" />
<ItemGroup>
<Content Remove="data\**" />
</ItemGroup>
<ItemGroup>
<None Remove="data\**" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,42 +0,0 @@
using MoonCore.Attributes;
namespace MoonlightServers.Daemon.Services;
[Singleton]
public class ApplicationStateService : IHostedLifecycleService
{
private readonly ServerService ServerService;
private readonly ILogger<ApplicationStateService> Logger;
public ApplicationStateService(ServerService serverService, ILogger<ApplicationStateService> logger)
{
ServerService = serverService;
Logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StartedAsync(CancellationToken cancellationToken)
{
Logger.LogInformation("Performing initialization");
await ServerService.Initialize();
}
public Task StartingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StoppingAsync(CancellationToken cancellationToken)
{
Logger.LogInformation("Stopping services");
await ServerService.Stop();
}
}

View File

@@ -0,0 +1,40 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using MoonCore.Attributes;
namespace MoonlightServers.Daemon.Services;
[Singleton]
public class DockerImageService
{
private readonly DockerClient DockerClient;
private readonly ILogger<DockerImageService> Logger;
public DockerImageService(DockerClient dockerClient, ILogger<DockerImageService> logger)
{
DockerClient = dockerClient;
Logger = logger;
}
public async Task Ensure(string name, Action<string>? onProgressUpdated)
{
await DockerClient.Images.CreateImageAsync(new()
{
FromImage = name
},
new AuthConfig(), // TODO: Config for custom registries
new Progress<JSONMessage>(async message =>
{
if (message.Progress == null)
return;
var line = $"[{message.ID}] {message.ProgressMessage}";
Logger.LogInformation("{line}", line);
if(onProgressUpdated != null)
onProgressUpdated.Invoke(line);
})
);
}
}

View File

@@ -2,29 +2,30 @@ using Docker.DotNet;
using Docker.DotNet.Models; using Docker.DotNet.Models;
using MoonCore.Attributes; using MoonCore.Attributes;
using MoonCore.Models; using MoonCore.Models;
using MoonlightServers.Daemon.Extensions.ServerExtensions; using MoonlightServers.Daemon.Abstractions;
using MoonlightServers.Daemon.Models;
using MoonlightServers.Daemon.Models.Cache; using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.DaemonShared.Enums;
using MoonlightServers.DaemonShared.PanelSide.Http.Responses; using MoonlightServers.DaemonShared.PanelSide.Http.Responses;
namespace MoonlightServers.Daemon.Services; namespace MoonlightServers.Daemon.Services;
[Singleton] [Singleton]
public class ServerService public class ServerService : IHostedLifecycleService
{ {
private readonly List<Server> Servers = new(); private readonly List<Server> Servers = new();
private readonly ILogger<ServerService> Logger; private readonly ILogger<ServerService> Logger;
private readonly RemoteService RemoteService; private readonly RemoteService RemoteService;
private readonly IServiceProvider ServiceProvider; private readonly IServiceProvider ServiceProvider;
private readonly ILoggerFactory LoggerFactory;
private bool IsInitialized = false; private bool IsInitialized = false;
private CancellationTokenSource Cancellation = new(); private CancellationTokenSource Cancellation = new();
public ServerService(RemoteService remoteService, ILogger<ServerService> logger, IServiceProvider serviceProvider) public ServerService(RemoteService remoteService, ILogger<ServerService> logger, IServiceProvider serviceProvider,
ILoggerFactory loggerFactory)
{ {
RemoteService = remoteService; RemoteService = remoteService;
Logger = logger; Logger = logger;
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
LoggerFactory = loggerFactory;
} }
public async Task Initialize() //TODO: Add initialize call from panel public async Task Initialize() //TODO: Add initialize call from panel
@@ -39,7 +40,7 @@ public class ServerService
// Loading models and converting them // Loading models and converting them
Logger.LogInformation("Fetching servers from panel"); Logger.LogInformation("Fetching servers from panel");
var apiClient = await RemoteService.CreateHttpClient(); using var apiClient = await RemoteService.CreateHttpClient();
var servers = await PagedData<ServerDataResponse>.All(async (page, pageSize) => var servers = await PagedData<ServerDataResponse>.All(async (page, pageSize) =>
await apiClient.GetJson<PagedData<ServerDataResponse>>( await apiClient.GetJson<PagedData<ServerDataResponse>>(
@@ -69,8 +70,7 @@ public class ServerService
Logger.LogInformation("Initializing {count} servers", servers.Length); Logger.LogInformation("Initializing {count} servers", servers.Length);
foreach (var configuration in configurations) await InitializeServerRange(configurations); // TODO: Initialize them multi threaded (maybe)
await InitializeServer(configuration);
// Attach to docker events // Attach to docker events
await AttachToDockerEvents(); await AttachToDockerEvents();
@@ -83,11 +83,13 @@ public class ServerService
lock (Servers) lock (Servers)
servers = Servers.ToArray(); servers = Servers.ToArray();
Logger.LogTrace("Canceling server sub tasks"); //
Logger.LogTrace("Canceling server tasks");
foreach (var server in servers) foreach (var server in servers)
await server.Cancellation.CancelAsync(); await server.CancelTasks();
//
Logger.LogTrace("Canceling own tasks"); Logger.LogTrace("Canceling own tasks");
await Cancellation.CancelAsync(); await Cancellation.CancelAsync();
} }
@@ -114,17 +116,19 @@ public class ServerService
Server? server; Server? server;
lock (Servers) lock (Servers)
server = Servers.FirstOrDefault(x => x.ContainerId == message.ID); server = Servers.FirstOrDefault(x => x.RuntimeContainerId == message.ID);
// TODO: Maybe implement a lookup for containers which id isn't set in the cache // TODO: Maybe implement a lookup for containers which id isn't set in the cache
if (server == null) if (server == null)
return; return;
await server.StateMachine.TransitionTo(ServerState.Offline); await server.NotifyContainerDied();
}), Cancellation.Token); }), Cancellation.Token);
} }
catch(TaskCanceledException){} // Can be ignored catch (TaskCanceledException)
{
} // Can be ignored
catch (Exception e) catch (Exception e)
{ {
Logger.LogError("An unhandled error occured while attaching to docker events: {e}", e); Logger.LogError("An unhandled error occured while attaching to docker events: {e}", e);
@@ -133,44 +137,47 @@ public class ServerService
}); });
} }
private async Task InitializeServer(ServerConfiguration configuration) private async Task InitializeServerRange(ServerConfiguration[] serverConfigurations)
{ {
Logger.LogTrace("Initializing server '{id}'", configuration.Id); var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
var loggerFactory = ServiceProvider.GetRequiredService<ILoggerFactory>(); var existingContainers = await dockerClient.Containers.ListContainersAsync(new()
var server = new Server()
{ {
Configuration = configuration, All = true,
StateMachine = new(ServerState.Offline), Limit = null,
ServiceProvider = ServiceProvider, Filters = new Dictionary<string, IDictionary<string, bool>>()
Logger = loggerFactory.CreateLogger($"Server {configuration.Id}"),
Console = new(),
Cancellation = new()
};
server.StateMachine.OnError += (state, exception) =>
{ {
server.Logger.LogError("Encountered an unhandled error while transitioning to {state}: {e}",
state,
exception
);
};
server.StateMachine.OnTransitioned += state =>
{ {
server.Logger.LogInformation("State: {state}", state); "label",
return Task.CompletedTask; new Dictionary<string, bool>()
}; {
{
"Software=Moonlight-Panel",
true
}
}
}
}
});
server.StateMachine.AddTransition(ServerState.Offline, ServerState.Starting, ServerState.Offline, async () => foreach (var configuration in serverConfigurations)
await server.StateMachineHandler_Start() await InitializeServer(configuration, existingContainers);
}
private async Task InitializeServer(
ServerConfiguration serverConfiguration,
IList<ContainerListResponse> existingContainers
)
{
Logger.LogTrace("Initializing server '{id}'", serverConfiguration.Id);
var server = new Server(
LoggerFactory.CreateLogger($"Server {serverConfiguration.Id}"),
ServiceProvider,
serverConfiguration
); );
server.StateMachine.AddTransition(ServerState.Starting, ServerState.Offline); await server.Initialize(existingContainers);
server.StateMachine.AddTransition(ServerState.Online, ServerState.Offline);
server.StateMachine.AddTransition(ServerState.Stopping, ServerState.Offline);
server.StateMachine.AddTransition(ServerState.Installing, ServerState.Offline);
lock (Servers) lock (Servers)
Servers.Add(server); Servers.Add(server);
@@ -179,6 +186,46 @@ public class ServerService
public Server? GetServer(int id) public Server? GetServer(int id)
{ {
lock (Servers) lock (Servers)
return Servers.FirstOrDefault(x => x.Configuration.Id == id); return Servers.FirstOrDefault(x => x.Id == id);
}
#region Lifecycle
public Task StartAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StartedAsync(CancellationToken cancellationToken)
{
try
{
await Initialize();
}
catch (Exception e)
{
Logger.LogCritical("Unable to initialize servers. Is the panel online? Error: {e}", e);
} }
} }
public Task StartingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StoppingAsync(CancellationToken cancellationToken)
{
try
{
await Stop();
}
catch (Exception e)
{
Logger.LogCritical("Unable to stop server handling: {e}", e);
}
}
#endregion
}

View File

@@ -6,15 +6,15 @@ using MoonlightServers.Daemon.Http.Hubs;
namespace MoonlightServers.Daemon.Services; namespace MoonlightServers.Daemon.Services;
[Singleton] [Singleton]
public class ServerConsoleService public class ServerWebSocketService
{ {
private readonly ILogger<ServerConsoleService> Logger; private readonly ILogger<ServerWebSocketService> Logger;
private readonly IServiceProvider ServiceProvider; private readonly IServiceProvider ServiceProvider;
private readonly Dictionary<string, ServerConsoleConnection> Connections = new(); private readonly Dictionary<string, ServerWebSocketConnection> Connections = new();
public ServerConsoleService( public ServerWebSocketService(
ILogger<ServerConsoleService> logger, ILogger<ServerWebSocketService> logger,
IServiceProvider serviceProvider IServiceProvider serviceProvider
) )
{ {
@@ -24,11 +24,11 @@ public class ServerConsoleService
public async Task InitializeClient(HubCallerContext context) public async Task InitializeClient(HubCallerContext context)
{ {
var connection = new ServerConsoleConnection( var connection = new ServerWebSocketConnection(
ServiceProvider.GetRequiredService<ServerService>(), ServiceProvider.GetRequiredService<ServerService>(),
ServiceProvider.GetRequiredService<ILogger<ServerConsoleConnection>>(), ServiceProvider.GetRequiredService<ILogger<ServerWebSocketConnection>>(),
ServiceProvider.GetRequiredService<AccessTokenHelper>(), ServiceProvider.GetRequiredService<AccessTokenHelper>(),
ServiceProvider.GetRequiredService<IHubContext<ServerConsoleHub>>() ServiceProvider.GetRequiredService<IHubContext<ServerWebSocketHub>>()
); );
lock (Connections) lock (Connections)
@@ -39,7 +39,7 @@ public class ServerConsoleService
public async Task AuthenticateClient(HubCallerContext context, string accessToken) public async Task AuthenticateClient(HubCallerContext context, string accessToken)
{ {
ServerConsoleConnection? connection; ServerWebSocketConnection? connection;
lock (Connections) lock (Connections)
connection = Connections.GetValueOrDefault(context.ConnectionId); connection = Connections.GetValueOrDefault(context.ConnectionId);
@@ -52,7 +52,7 @@ public class ServerConsoleService
public async Task DestroyClient(HubCallerContext context) public async Task DestroyClient(HubCallerContext context)
{ {
ServerConsoleConnection? connection; ServerWebSocketConnection? connection;
lock (Connections) lock (Connections)
connection = Connections.GetValueOrDefault(context.ConnectionId); connection = Connections.GetValueOrDefault(context.ConnectionId);

View File

@@ -249,8 +249,8 @@ public class Startup
private Task RegisterServers() private Task RegisterServers()
{ {
WebApplicationBuilder.Services.AddHostedService<ApplicationStateService>( WebApplicationBuilder.Services.AddHostedService(
sp => sp.GetRequiredService<ApplicationStateService>() sp => sp.GetRequiredService<ServerService>()
); );
return Task.CompletedTask; return Task.CompletedTask;
@@ -263,7 +263,7 @@ public class Startup
#endregion #endregion
#region Maps #region Hubs
private Task RegisterSignalR() private Task RegisterSignalR()
{ {
@@ -273,7 +273,7 @@ public class Startup
private Task MapHubs() private Task MapHubs()
{ {
WebApplication.MapHub<ServerConsoleHub>("api/servers/console"); WebApplication.MapHub<ServerWebSocketHub>("api/servers/ws");
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -1,12 +0,0 @@
namespace MoonlightServers.DaemonShared.Enums;
public enum ServerTask
{
None = 0,
CreatingStorage = 1,
PullingDockerImage = 2,
RemovingContainer = 3,
CreatingContainer = 4,
StartingContainer = 5,
StoppingContainer = 6
}

View File

@@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "Styles",
"dependencies": { "dependencies": {
"@tailwindcss/forms": "^0.5.9" "@tailwindcss/forms": "^0.5.9"
}, },
@@ -610,9 +611,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",

View File

@@ -41,7 +41,9 @@
{ {
Form = new() Form = new()
{ {
IpAddress = "0.0.0.0" IpAddress = "0.0.0.0",
Start = 2000,
End = 3000
}; };
} }

View File

@@ -11,23 +11,23 @@
if (IsLoaded && !IsFailed) if (IsLoaded && !IsFailed)
{ {
gradient = Status.PowerState switch gradient = Status.State switch
{ {
ServerPowerState.Installing => "from-primary-600/20", ServerState.Installing => "from-primary-600/20",
ServerPowerState.Offline => "from-danger-600/20", ServerState.Offline => "from-danger-600/20",
ServerPowerState.Starting => "from-warning-600/20", ServerState.Starting => "from-warning-600/20",
ServerPowerState.Stopping => "from-warning-600/20", ServerState.Stopping => "from-warning-600/20",
ServerPowerState.Online => "from-success-600/20", ServerState.Online => "from-success-600/20",
_ => "from-gray-600/20" _ => "from-gray-600/20"
}; };
border = Status.PowerState switch border = Status.State switch
{ {
ServerPowerState.Installing => "border-primary-600", ServerState.Installing => "border-primary-600",
ServerPowerState.Offline => "border-danger-600", ServerState.Offline => "border-danger-600",
ServerPowerState.Starting => "border-warning-600", ServerState.Starting => "border-warning-600",
ServerPowerState.Stopping => "border-warning-600", ServerState.Stopping => "border-warning-600",
ServerPowerState.Online => "border-success-600", ServerState.Online => "border-success-600",
_ => "border-gray-600" _ => "border-gray-600"
}; };
} }
@@ -49,7 +49,7 @@
@if ( @if (
IsLoaded && IsLoaded &&
!IsFailed && !IsFailed &&
Status.PowerState is ServerPowerState.Starting or ServerPowerState.Stopping or ServerPowerState.Online Status.State is ServerState.Starting or ServerState.Stopping or ServerState.Online
) )
{ {
<div class="bg-gray-900 bg-opacity-45 py-1 px-2 rounded-lg flex flex-row"> <div class="bg-gray-900 bg-opacity-45 py-1 px-2 rounded-lg flex flex-row">
@@ -98,7 +98,7 @@
<div class="ms-3">Unreachable</div> <div class="ms-3">Unreachable</div>
</div> </div>
} }
else if (IsLoaded && !IsFailed && Status.PowerState is ServerPowerState.Offline) else if (IsLoaded && !IsFailed && Status.State is ServerState.Offline)
{ {
<div class="bg-gray-900 bg-opacity-45 py-1 px-2 rounded-lg flex flex-row text-danger-500"> <div class="bg-gray-900 bg-opacity-45 py-1 px-2 rounded-lg flex flex-row text-danger-500">
<div> <div>
@@ -108,7 +108,7 @@
<div class="ms-3">Offline</div> <div class="ms-3">Offline</div>
</div> </div>
} }
else if (IsLoaded && !IsFailed && Status.PowerState is ServerPowerState.Installing) else if (IsLoaded && !IsFailed && Status.State is ServerState.Installing)
{ {
<div class="bg-gray-900 bg-opacity-45 py-1 px-2 rounded-lg flex flex-row text-primary-500"> <div class="bg-gray-900 bg-opacity-45 py-1 px-2 rounded-lg flex flex-row text-primary-500">
<div> <div>

View File

@@ -15,9 +15,11 @@
<LazyLoader Load="Load"> <LazyLoader Load="Load">
@if (NotFound) @if (NotFound)
{ {
<div class="flex flex-col justify-center text-center"><img class="h-48 mt-5 mb-3" src="/svg/notfound.svg" <div class="flex flex-col justify-center text-center">
alt="Not found illustration"> <img class="h-48 mt-5 mb-3" src="/svg/notfound.svg" alt="Not found illustration">
<h3 class="mt-2 font-semibold text-white text-lg">Server not found</h3> <h3 class="mt-2 font-semibold text-white text-lg">
Server not found
</h3>
<p class="mt-1 text-gray-300"> <p class="mt-1 text-gray-300">
The server you requested does not exist The server you requested does not exist
</p> </p>
@@ -28,13 +30,13 @@
<div class="card card-body justify-between py-2.5 px-5 flex-row"> <div class="card card-body justify-between py-2.5 px-5 flex-row">
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
@{ @{
var bgColor = PowerState switch var bgColor = State switch
{ {
ServerPowerState.Installing => "bg-primary-500", ServerState.Installing => "bg-primary-500",
ServerPowerState.Offline => "bg-danger-500", ServerState.Offline => "bg-danger-500",
ServerPowerState.Starting => "bg-warning-500", ServerState.Starting => "bg-warning-500",
ServerPowerState.Stopping => "bg-warning-500", ServerState.Stopping => "bg-warning-500",
ServerPowerState.Online => "bg-success-500", ServerState.Online => "bg-success-500",
_ => "bg-gray-500" _ => "bg-gray-500"
}; };
} }
@@ -57,29 +59,39 @@
</div> </div>
</div> </div>
<div class="flex flex-row items-center"> <div class="flex flex-row items-center">
@if (!string.IsNullOrEmpty(CurrentTask))
{
<div class="hidden md:flex me-8 items-center text-gray-600">
<i class="icon-loader me-1.5 animate-spin text-lg align-middle"></i>
<span class="text-base align-middle">
@CurrentTask
</span>
</div>
}
<div class="flex gap-x-1.5"> <div class="flex gap-x-1.5">
<button class="btn btn-success"> @if (State == ServerState.Offline)
{
<WButton CssClasses="btn btn-success" OnClick="_ => Start()">
<i class="icon-play me-1 align-middle"></i>
<span class="align-middle">Start</span>
</WButton>
}
else
{
<button type="button" class="btn btn-success" disabled="disabled">
<i class="icon-play me-1 align-middle"></i> <i class="icon-play me-1 align-middle"></i>
<span class="align-middle">Start</span> <span class="align-middle">Start</span>
</button> </button>
}
<button type="button" class="btn btn-primary"> <button type="button" class="btn btn-primary">
<i class="icon-rotate-ccw me-1 align-middle"></i> <i class="icon-rotate-ccw me-1 align-middle"></i>
<span class="align-middle">Restart</span> <span class="align-middle">Restart</span>
</button> </button>
<button type="button" class="btn btn-danger"> @if (State == ServerState.Starting || State == ServerState.Online)
{
<WButton CssClasses="btn btn-danger" OnClick="_ => Stop()">
<i class="icon-squircle me-1 align-middle"></i>
<span class="align-middle">Stop</span>
</WButton>
}
else
{
<button type="button" class="btn btn-danger" disabled="disabled">
<i class="icon-squircle me-1 align-middle"></i> <i class="icon-squircle me-1 align-middle"></i>
<span class="align-middle">Stop</span> <span class="align-middle">Stop</span>
</button> </button>
}
</div> </div>
</div> </div>
</div> </div>
@@ -87,25 +99,39 @@
<div class="mt-5 mx-2 relative"> <div class="mt-5 mx-2 relative">
<ul class="relative text-sm font-medium flex flex-nowrap -mx-4 sm:-mx-6 lg:-mx-8 overflow-x-scroll no-scrollbar"> <ul class="relative text-sm font-medium flex flex-nowrap -mx-4 sm:-mx-6 lg:-mx-8 overflow-x-scroll no-scrollbar">
<li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8"><a <li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8">
<a
href="/admin/servers/all" href="/admin/servers/all"
class="block pb-3 text-white whitespace-nowrap border-b-2 border-primary-500">Console</a></li> class="block pb-3 text-white whitespace-nowrap border-b-2 border-primary-500">Console</a>
</li>
<li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8"><a <li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8">
<a
href="/admin/servers" href="/admin/servers"
class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">Files</a> class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">
Files
</a>
</li> </li>
<li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8"><a <li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8">
<a
href="/admin/servers/nodes" href="/admin/servers/nodes"
class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">Backups</a> class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">
Backups
</a>
</li> </li>
<li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8"><a <li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8">
<a
href="/admin/servers/stars" href="/admin/servers/stars"
class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">Networking</a> class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">
Networking
</a>
</li> </li>
<li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8"><a <li class="mr-6 last:mr-0 first:pl-4 sm:first:pl-6 lg:first:pl-8 last:pr-4 sm:last:pr-6 lg:last:pr-8">
<a
href="/admin/servers/manager" href="/admin/servers/manager"
class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">Variables</a> class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover:border-primary-500">
Variables
</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -122,13 +148,12 @@
private ServerDetailResponse Server; private ServerDetailResponse Server;
private bool NotFound = false; private bool NotFound = false;
private ServerPowerState PowerState; private ServerState State;
private string InitialConsoleMessage; // TODO: When moving to a single component, fail safe when failed to load private string InitialConsoleMessage; // TODO: When moving to a single component, fail safe when failed to load
private string CurrentTask = "";
private XtermConsole? XtermConsole; private XtermConsole? XtermConsole;
private HubConnection ConsoleConnection; private HubConnection WebSocketConnection;
private async Task Load(LazyLoader _) private async Task Load(LazyLoader _)
{ {
@@ -144,7 +169,7 @@
$"api/servers/{ServerId}/status" $"api/servers/{ServerId}/status"
); );
PowerState = status.PowerState; State = status.State;
// Load initial messages // Load initial messages
var initialLogs = await ApiClient.GetJson<ServerLogsResponse>( var initialLogs = await ApiClient.GetJson<ServerLogsResponse>(
@@ -156,32 +181,27 @@
foreach (var message in initialLogs.Messages) foreach (var message in initialLogs.Messages)
InitialConsoleMessage += message; InitialConsoleMessage += message;
// Load console meta // Load websocket meta
var consoleDetails = await ApiClient.GetJson<ServerConsoleResponse>( var websocketDetails = await ApiClient.GetJson<ServerWebSocketResponse>(
$"api/servers/{ServerId}/console" $"api/servers/{ServerId}/ws"
); );
// Build signal r // Build signal r
ConsoleConnection = new HubConnectionBuilder() WebSocketConnection = new HubConnectionBuilder()
.WithUrl(consoleDetails.Target) .WithUrl(websocketDetails.Target)
.Build(); .Build();
// Define handlers // Define handlers
ConsoleConnection.On<string>("PowerStateChanged", async powerStateStr => WebSocketConnection.On<string>("StateChanged", async stateStr =>
{ {
if(!Enum.TryParse(powerStateStr, out ServerPowerState receivedState)) if (!Enum.TryParse(stateStr, out ServerState receivedState))
return; return;
PowerState = receivedState; State = receivedState;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
}); });
ConsoleConnection.On<string>("TaskNotify", async task => WebSocketConnection.On<string>("ConsoleOutput", async content =>
{
await AddTask(Formatter.ConvertCamelCaseToSpaces(task));
});
ConsoleConnection.On<string>("ConsoleOutput", async content =>
{ {
if (XtermConsole != null) if (XtermConsole != null)
await XtermConsole.Write(content); await XtermConsole.Write(content);
@@ -190,10 +210,10 @@
}); });
// Connect // Connect
await ConsoleConnection.StartAsync(); await WebSocketConnection.StartAsync();
// Authenticate // Authenticate
await ConsoleConnection.SendAsync("Authenticate", consoleDetails.AccessToken); await WebSocketConnection.SendAsync("Authenticate", websocketDetails.AccessToken);
} }
catch (HttpApiException e) catch (HttpApiException e)
{ {
@@ -209,28 +229,21 @@
await XtermConsole!.Write(InitialConsoleMessage); await XtermConsole!.Write(InitialConsoleMessage);
} }
private async Task AddTask(string message) private async Task Start()
{ {
CurrentTask = message; await ApiClient.Post($"api/servers/{Server.Id}/start");
await InvokeAsync(StateHasChanged); }
Task.Run(async () => private async Task Stop()
{ {
await Task.Delay(3000); await ApiClient.Post($"api/servers/{Server.Id}/stop");
if (CurrentTask != message)
return;
CurrentTask = "";
await InvokeAsync(StateHasChanged);
});
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (ConsoleConnection.State == HubConnectionState.Connected) if (WebSocketConnection.State == HubConnectionState.Connected)
await ConsoleConnection.StopAsync(); await WebSocketConnection.StopAsync();
await ConsoleConnection.DisposeAsync(); await WebSocketConnection.DisposeAsync();
} }
} }

View File

@@ -1,6 +1,6 @@
namespace MoonlightServers.Shared.Enums; namespace MoonlightServers.Shared.Enums;
public enum ServerPowerState public enum ServerState
{ {
Offline = 0, Offline = 0,
Starting = 1, Starting = 1,

View File

@@ -4,5 +4,5 @@ namespace MoonlightServers.Shared.Http.Responses.Users.Servers;
public class ServerStatusResponse public class ServerStatusResponse
{ {
public ServerPowerState PowerState { get; set; } public ServerState State { get; set; }
} }

View File

@@ -1,6 +1,6 @@
namespace MoonlightServers.Shared.Http.Responses.Users.Servers; namespace MoonlightServers.Shared.Http.Responses.Users.Servers;
public class ServerConsoleResponse public class ServerWebSocketResponse
{ {
public string Target { get; set; } public string Target { get; set; }
public string AccessToken { get; set; } public string AccessToken { get; set; }