diff --git a/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs b/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs index 1d1f3fe..bfa1b87 100644 --- a/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs +++ b/MoonlightServers.ApiServer/Http/Controllers/Users/ServersController.cs @@ -211,6 +211,24 @@ public class ServersController : Controller throw new HttpApiException("Unable to access the node the server is running on", 502); } } + + [HttpPost("{serverId:int}/kill")] + [Authorize] + public async Task Kill([FromRoute] int serverId) + { + var server = await GetServerWithPermCheck(serverId); + + using var apiClient = await NodeService.CreateApiClient(server.Node); + + try + { + await apiClient.Post($"api/servers/{server.Id}/kill"); + } + catch (HttpRequestException e) + { + throw new HttpApiException("Unable to access the node the server is running on", 502); + } + } private async Task GetServerWithPermCheck(int serverId, Func, IQueryable>? queryModifier = null) diff --git a/MoonlightServers.Daemon/Abstractions/Server.Crash.cs b/MoonlightServers.Daemon/Abstractions/Server.Crash.cs index 52cbfc0..aa830dd 100644 --- a/MoonlightServers.Daemon/Abstractions/Server.Crash.cs +++ b/MoonlightServers.Daemon/Abstractions/Server.Crash.cs @@ -31,4 +31,11 @@ public partial class Server await Destroy(); } + + public async Task InternalError() + { + await LogToConsole("An unhandled error occured performing action"); + + Logger.LogInformation("Reporting or smth"); + } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Initialize.cs b/MoonlightServers.Daemon/Abstractions/Server.Initialize.cs index 1e0d26f..095a7de 100644 --- a/MoonlightServers.Daemon/Abstractions/Server.Initialize.cs +++ b/MoonlightServers.Daemon/Abstractions/Server.Initialize.cs @@ -35,31 +35,36 @@ public partial class Server // Setup transitions StateMachine.Configure(ServerState.Offline) - .Permit(ServerTrigger.Start, ServerState.Starting) - .Permit(ServerTrigger.Reinstall, ServerState.Installing); + .Permit(ServerTrigger.Start, ServerState.Starting) // Allow to start + .Permit(ServerTrigger.Reinstall, ServerState.Installing) // Allow to install + .OnEntryFromAsync(ServerTrigger.NotifyInternalError, InternalError); // Handle unhandled errors StateMachine.Configure(ServerState.Starting) - .Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) - .Permit(ServerTrigger.NotifyOnline, ServerState.Online) - .Permit(ServerTrigger.Stop, ServerState.Stopping) - .OnEntryAsync(InternalStart) - .OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalCrash); + .Permit(ServerTrigger.Stop, ServerState.Stopping) // Allow stopping + .Permit(ServerTrigger.NotifyOnline, ServerState.Online) // Allow the server to report as online + .Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) // Allow server to handle container death + .Permit(ServerTrigger.NotifyInternalError, ServerState.Offline) // Error handling for the action below + .OnEntryAsync(InternalStart) // Perform start action + .OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalCrash); // Define a runtime container death as a crash StateMachine.Configure(ServerState.Online) - .Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) - .Permit(ServerTrigger.Stop, ServerState.Stopping) - .OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalCrash); + .Permit(ServerTrigger.Stop, ServerState.Stopping) // Allow stopping + .Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) // Allow server to handle container death + .OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalCrash); // Define a runtime container death as a crash StateMachine.Configure(ServerState.Stopping) - .Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) - .Permit(ServerTrigger.Kill, ServerState.Offline) - .OnEntryAsync(InternalStop) - .OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalFinishStop); + .PermitReentry(ServerTrigger.Kill) // Allow killing, will return to stopping to trigger kill and handle the death correctly + .Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) // Allow server to handle container death + .Permit(ServerTrigger.NotifyInternalError, ServerState.Offline) // Error handling for the actions below + .OnEntryFromAsync(ServerTrigger.Stop, InternalStop) // Perform stop action + .OnEntryFromAsync(ServerTrigger.Kill, InternalKill) // Perform kill action + .OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalFinishStop); // Define a runtime container death as a successful stop StateMachine.Configure(ServerState.Installing) - .Permit(ServerTrigger.NotifyInstallationContainerDied, ServerState.Offline) - .OnEntryAsync(InternalInstall) - .OnExitAsync(InternalFinishInstall); + .Permit(ServerTrigger.NotifyInstallationContainerDied, ServerState.Offline) // Allow server to handle container death + .Permit(ServerTrigger.NotifyInternalError, ServerState.Offline) // Error handling for the action below + .OnEntryAsync(InternalInstall) // Perform install action + .OnExitFromAsync(ServerTrigger.NotifyInstallationContainerDied, InternalFinishInstall); // Define the death of the installation container as successful return Task.CompletedTask; } diff --git a/MoonlightServers.Daemon/Abstractions/Server.Installation.cs b/MoonlightServers.Daemon/Abstractions/Server.Installation.cs index 72fd096..25c94f7 100644 --- a/MoonlightServers.Daemon/Abstractions/Server.Installation.cs +++ b/MoonlightServers.Daemon/Abstractions/Server.Installation.cs @@ -14,69 +14,79 @@ public partial class Server private async Task InternalInstall() { - // TODO: Consider if checking for existing install containers is actually useful, because - // when the daemon is starting and a installation is still ongoing it will reattach anyways - // and the container has the auto remove flag enabled by default (maybe also consider this for the normal runtime container) - - await LogToConsole("Fetching installation configuration"); - - // Fetching remote configuration - var remoteService = ServiceProvider.GetRequiredService(); - using var remoteHttpClient = await remoteService.CreateHttpClient(); - - var installData = await remoteHttpClient.GetJson($"api/servers/remote/servers/{Configuration.Id}/install"); - - var dockerImageService = ServiceProvider.GetRequiredService(); - - // 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(installData.DockerImage, async message => { await LogToConsole(message); }); - - // Ensuring storage configuration - var installationHostPath = await EnsureInstallationVolume(); - var runtimeHostPath = await EnsureRuntimeVolume(); - - // Write installation script to path - var content = installData.Script.Replace("\r\n", "\n"); - await File.WriteAllTextAsync(PathBuilder.File(installationHostPath, "install.sh"), content); - - // Creating container configuration - var parameters = Configuration.ToInstallationCreateParameters( - runtimeHostPath, - installationHostPath, - InstallationContainerName, - installData.DockerImage, - installData.Shell - ); - - var dockerClient = ServiceProvider.GetRequiredService(); - - // Ensure we can actually spawn the container - try { - var existingContainer = await dockerClient.Containers.InspectContainerAsync(InstallationContainerName); + // TODO: Consider if checking for existing install containers is actually useful, because + // when the daemon is starting and a installation is still ongoing it will reattach anyways + // and the container has the auto remove flag enabled by default (maybe also consider this for the normal runtime container) + + await LogToConsole("Fetching installation configuration"); + + // Fetching remote configuration + var remoteService = ServiceProvider.GetRequiredService(); + using var remoteHttpClient = await remoteService.CreateHttpClient(); + + var installData = + await remoteHttpClient.GetJson( + $"api/servers/remote/servers/{Configuration.Id}/install"); + + var dockerImageService = ServiceProvider.GetRequiredService(); + + // 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(installData.DockerImage, async message => { await LogToConsole(message); }); + + // Ensuring storage configuration + var installationHostPath = await EnsureInstallationVolume(); + var runtimeHostPath = await EnsureRuntimeVolume(); + + // Write installation script to path + var content = installData.Script.Replace("\r\n", "\n"); + await File.WriteAllTextAsync(PathBuilder.File(installationHostPath, "install.sh"), content); + + // Creating container configuration + var parameters = Configuration.ToInstallationCreateParameters( + runtimeHostPath, + installationHostPath, + InstallationContainerName, + installData.DockerImage, + installData.Shell + ); + + var dockerClient = ServiceProvider.GetRequiredService(); + + // Ensure we can actually spawn the container + + try + { + var existingContainer = await dockerClient.Containers.InspectContainerAsync(InstallationContainerName); + + // Perform automatic cleanup / restore + + if (existingContainer.State.Running) + await dockerClient.Containers.KillContainerAsync(existingContainer.ID, new()); + + await dockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new()); + } + catch (DockerContainerNotFoundException) + { + // Ignored + } - // Perform automatic cleanup / restore - - if (existingContainer.State.Running) - await dockerClient.Containers.KillContainerAsync(existingContainer.ID, new()); - - await dockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new()); + // Spawn the container + + var container = await dockerClient.Containers.CreateContainerAsync(parameters); + InstallationContainerId = container.ID; + + await AttachConsole(InstallationContainerId); + + await dockerClient.Containers.StartContainerAsync(InstallationContainerId, new()); } - catch (DockerContainerNotFoundException) + catch (Exception e) { - // Ignored + Logger.LogError("An error occured while performing install trigger: {e}", e); + await StateMachine.FireAsync(ServerTrigger.NotifyInternalError); } - - // Spawn the container - - var container = await dockerClient.Containers.CreateContainerAsync(parameters); - InstallationContainerId = container.ID; - - await AttachConsole(InstallationContainerId); - - await dockerClient.Containers.StartContainerAsync(InstallationContainerId, new()); } private async Task InternalFinishInstall() diff --git a/MoonlightServers.Daemon/Abstractions/Server.Kill.cs b/MoonlightServers.Daemon/Abstractions/Server.Kill.cs new file mode 100644 index 0000000..9f08316 --- /dev/null +++ b/MoonlightServers.Daemon/Abstractions/Server.Kill.cs @@ -0,0 +1,28 @@ +using Docker.DotNet; +using MoonlightServers.Daemon.Enums; + +namespace MoonlightServers.Daemon.Abstractions; + +public partial class Server +{ + public async Task Kill() => await StateMachine.FireAsync(ServerTrigger.Kill); + + private async Task InternalKill() + { + try + { + if (RuntimeContainerId == null) + return; + + await LogToConsole("Killing container"); + + var dockerClient = ServiceProvider.GetRequiredService(); + await dockerClient.Containers.KillContainerAsync(RuntimeContainerId, new()); + } + catch (Exception e) + { + Logger.LogError("An error occured while performing stop trigger: {e}", e); + await StateMachine.FireAsync(ServerTrigger.NotifyInternalError); + } + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Start.cs b/MoonlightServers.Daemon/Abstractions/Server.Start.cs index afb98a6..bff61ff 100644 --- a/MoonlightServers.Daemon/Abstractions/Server.Start.cs +++ b/MoonlightServers.Daemon/Abstractions/Server.Start.cs @@ -9,15 +9,23 @@ public partial class Server private async Task InternalStart() { - await ReCreate(); + try + { + await ReCreate(); - await LogToConsole("Starting container"); + 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(); - await dockerClient.Containers.StartContainerAsync(RuntimeContainerId, new()); + // 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(); + await dockerClient.Containers.StartContainerAsync(RuntimeContainerId, new()); + } + catch (Exception e) + { + Logger.LogError("An error occured while performing start trigger: {e}", e); + await StateMachine.FireAsync(ServerTrigger.NotifyInternalError); + } } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Stop.cs b/MoonlightServers.Daemon/Abstractions/Server.Stop.cs index 0fdc69c..174009c 100644 --- a/MoonlightServers.Daemon/Abstractions/Server.Stop.cs +++ b/MoonlightServers.Daemon/Abstractions/Server.Stop.cs @@ -8,7 +8,15 @@ public partial class Server private async Task InternalStop() { - await Console.WriteToInput($"{Configuration.StopCommand}\n\r"); + try + { + await Console.WriteToInput($"{Configuration.StopCommand}\n\r"); + } + catch (Exception e) + { + Logger.LogError("An error occured while performing stop trigger: {e}", e); + await StateMachine.FireAsync(ServerTrigger.NotifyInternalError); + } } private async Task InternalFinishStop() diff --git a/MoonlightServers.Daemon/Enums/ServerTrigger.cs b/MoonlightServers.Daemon/Enums/ServerTrigger.cs index 2e1ebe6..ddc83f2 100644 --- a/MoonlightServers.Daemon/Enums/ServerTrigger.cs +++ b/MoonlightServers.Daemon/Enums/ServerTrigger.cs @@ -9,5 +9,6 @@ public enum ServerTrigger Reinstall = 4, NotifyOnline = 5, NotifyRuntimeContainerDied = 6, - NotifyInstallationContainerDied = 7 + NotifyInstallationContainerDied = 7, + NotifyInternalError = 8 } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs b/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs index bf16335..718dd36 100644 --- a/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs +++ b/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs @@ -17,7 +17,7 @@ public class ServerPowerController : Controller } [HttpPost("{serverId:int}/start")] - public async Task Start(int serverId, [FromQuery] bool runAsync = true) + public async Task Start(int serverId) { var server = ServerService.GetServer(serverId); @@ -28,7 +28,7 @@ public class ServerPowerController : Controller } [HttpPost("{serverId:int}/stop")] - public async Task Stop(int serverId, [FromQuery] bool runAsync = true) + public async Task Stop(int serverId) { var server = ServerService.GetServer(serverId); @@ -39,7 +39,7 @@ public class ServerPowerController : Controller } [HttpPost("{serverId:int}/install")] - public async Task Install(int serverId, [FromQuery] bool runAsync = true) + public async Task Install(int serverId) { var server = ServerService.GetServer(serverId); @@ -48,4 +48,15 @@ public class ServerPowerController : Controller await server.Install(); } + + [HttpPost("{serverId:int}/kill")] + public async Task Kill(int serverId) + { + var server = ServerService.GetServer(serverId); + + if (server == null) + throw new HttpApiException("No server with this id found", 404); + + await server.Kill(); + } } \ No newline at end of file diff --git a/MoonlightServers.Frontend/Styles/additions/buttons.css b/MoonlightServers.Frontend/Styles/additions/buttons.css index 02e1385..97de7f1 100644 --- a/MoonlightServers.Frontend/Styles/additions/buttons.css +++ b/MoonlightServers.Frontend/Styles/additions/buttons.css @@ -26,31 +26,31 @@ /* Colors */ .btn-primary { - @apply bg-primary-600 hover:bg-primary-500 focus-visible:outline-primary-600; + @apply bg-primary-600 hover:bg-primary-500 focus-visible:outline-primary-600 text-diffcolor; } .btn-secondary { - @apply bg-secondary-800 hover:bg-secondary-700 focus-visible:outline-secondary-800; + @apply bg-secondary-800 hover:bg-secondary-700 focus-visible:outline-secondary-800 text-diffcolor; } .btn-tertiary { - @apply bg-tertiary-600 hover:bg-tertiary-500 focus-visible:outline-tertiary-600; + @apply bg-tertiary-600 hover:bg-tertiary-500 focus-visible:outline-tertiary-600 text-diffcolor; } .btn-danger { - @apply bg-danger-600 hover:bg-danger-500 focus-visible:outline-danger-600; + @apply bg-danger-600 hover:bg-danger-500 focus-visible:outline-danger-600 text-diffcolor; } .btn-warning { - @apply bg-warning-500 hover:bg-warning-400 focus-visible:outline-warning-500; + @apply bg-warning-500 hover:bg-warning-400 focus-visible:outline-warning-500 text-diffcolor; } .btn-info { - @apply bg-info-600 hover:bg-info-500 focus-visible:outline-info-600; + @apply bg-info-600 hover:bg-info-500 focus-visible:outline-info-600 text-diffcolor; } .btn-success { - @apply bg-success-600 hover:bg-success-500 focus-visible:outline-success-600; + @apply bg-success-600 hover:bg-success-500 focus-visible:outline-success-600 text-diffcolor; } /* Outline */ @@ -77,4 +77,69 @@ .btn-outline-success { @apply bg-gray-800 hover:border-gray-600 text-success-500; +} + +/* Disabled Buttons */ + +.btn:disabled, +.btn-lg:disabled, +.btn-sm:disabled, +.btn-xs:disabled { + @apply opacity-50 cursor-not-allowed pointer-events-none; +} + +/* Colors for Disabled States */ + +.btn-primary:disabled { + @apply bg-primary-600 text-gray-300; +} + +.btn-secondary:disabled { + @apply bg-secondary-800 text-gray-400; +} + +.btn-tertiary:disabled { + @apply bg-tertiary-600 text-gray-300; +} + +.btn-danger:disabled { + @apply bg-danger-600 text-gray-300; +} + +.btn-warning:disabled { + @apply bg-warning-500 text-gray-400; +} + +.btn-info:disabled { + @apply bg-info-600 text-gray-300; +} + +.btn-success:disabled { + @apply bg-success-600 text-gray-300; +} + +/* Outline Disabled States */ + +.btn-outline-primary:disabled { + @apply bg-gray-800 border-gray-700 text-gray-500; +} + +.btn-outline-tertiary:disabled { + @apply bg-gray-800 border-gray-700 text-gray-500; +} + +.btn-outline-danger:disabled { + @apply bg-gray-800 border-gray-700 text-gray-500; +} + +.btn-outline-warning:disabled { + @apply bg-gray-800 border-gray-700 text-gray-500; +} + +.btn-outline-info:disabled { + @apply bg-gray-800 border-gray-700 text-gray-500; +} + +.btn-outline-success:disabled { + @apply bg-gray-800 border-gray-700 text-gray-500; } \ No newline at end of file diff --git a/MoonlightServers.Frontend/Styles/tailwind.config.js b/MoonlightServers.Frontend/Styles/tailwind.config.js index 891d42e..a1e4a1f 100644 --- a/MoonlightServers.Frontend/Styles/tailwind.config.js +++ b/MoonlightServers.Frontend/Styles/tailwind.config.js @@ -119,6 +119,9 @@ module.exports = { 950: '#0e121c', } }, + textColor:{ + diffcolor: 'rgb(var(--color-diffcolor, var(--color-light)))' + }, animation: { 'shimmer': 'shimmer 2s linear infinite', } diff --git a/MoonlightServers.Frontend/UI/Views/User/Manage.razor b/MoonlightServers.Frontend/UI/Views/User/Manage.razor index e6e00e2..8d1f4da 100644 --- a/MoonlightServers.Frontend/UI/Views/User/Manage.razor +++ b/MoonlightServers.Frontend/UI/Views/User/Manage.razor @@ -74,16 +74,38 @@ Start } - - @if (State == ServerState.Starting || State == ServerState.Online) + + @if (State == ServerState.Online) { - - - Stop - + + } + else + { + + } + + @if (State == ServerState.Starting || State == ServerState.Online || State == ServerState.Stopping) + { + if (State == ServerState.Stopping) + { + + + Kill + + } + else + { + + + Stop + + } } else { @@ -230,14 +252,13 @@ } private async Task Start() - { - await ApiClient.Post($"api/servers/{Server.Id}/start"); - } + => await ApiClient.Post($"api/servers/{Server.Id}/start"); private async Task Stop() - { - await ApiClient.Post($"api/servers/{Server.Id}/stop"); - } + => await ApiClient.Post($"api/servers/{Server.Id}/stop"); + + private async Task Kill() + => await ApiClient.Post($"api/servers/{Server.Id}/kill"); public async ValueTask DisposeAsync() {