Implemented server console streaming in the frontend with xterm. Added logs endpoint for servers
This commit is contained in:
@@ -125,7 +125,7 @@ public class ServersController : Controller
|
|||||||
{
|
{
|
||||||
var server = await GetServerWithPermCheck(serverId);
|
var server = await GetServerWithPermCheck(serverId);
|
||||||
|
|
||||||
// TODO: Handle transparent proxy
|
// TODO: Handle transparent node proxy
|
||||||
|
|
||||||
var accessToken = NodeService.CreateAccessToken(server.Node, parameters =>
|
var accessToken = NodeService.CreateAccessToken(server.Node, parameters =>
|
||||||
{
|
{
|
||||||
@@ -149,6 +149,31 @@ public class ServersController : Controller
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{serverId:int}/logs")]
|
||||||
|
[RequirePermission("meta.authenticated")]
|
||||||
|
public async Task<ServerLogsResponse> GetLogs([FromRoute] int serverId)
|
||||||
|
{
|
||||||
|
var server = await GetServerWithPermCheck(serverId);
|
||||||
|
|
||||||
|
var apiClient = await NodeService.CreateApiClient(server.Node);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = await apiClient.GetJson<DaemonShared.DaemonSide.Http.Responses.Servers.ServerLogsResponse>(
|
||||||
|
$"api/servers/{server.Id}/logs"
|
||||||
|
);
|
||||||
|
|
||||||
|
return new ServerLogsResponse()
|
||||||
|
{
|
||||||
|
Messages = data.Messages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
"MOONLIGHT_APP_PUBLICURL": "http://localhost:5269"
|
"MOONLIGHT_APP_PUBLICURL": "http://localhost:5269"
|
||||||
}
|
},
|
||||||
|
"commandLineArgs": "--frontend-asset js/XtermBlazor.min.js --frontend-asset js/moonlightServers.js --frontend-asset js/addon-fit.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class PluginStartup : IAppStartup
|
|||||||
builder.Services.AutoAddServices<PluginStartup>();
|
builder.Services.AutoAddServices<PluginStartup>();
|
||||||
|
|
||||||
BundleService.BundleCss("css/MoonlightServers.min.css");
|
BundleService.BundleCss("css/MoonlightServers.min.css");
|
||||||
|
BundleService.BundleCss("css/XtermBlazor.min.css");
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ public static class ServerConsoleExtensions
|
|||||||
{
|
{
|
||||||
// Ignored
|
// Ignored
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Ignored
|
||||||
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
server.Logger.LogWarning("An unhandled error occured while reading from container stream: {e}", e);
|
server.Logger.LogWarning("An unhandled error occured while reading from container stream: {e}", e);
|
||||||
|
|||||||
@@ -1,7 +1,149 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using MoonlightServers.Daemon.Http.Hubs;
|
||||||
|
using MoonlightServers.Daemon.Models;
|
||||||
|
using MoonlightServers.Daemon.Services;
|
||||||
|
using MoonlightServers.DaemonShared.Enums;
|
||||||
|
|
||||||
namespace MoonlightServers.Daemon.Helpers;
|
namespace MoonlightServers.Daemon.Helpers;
|
||||||
|
|
||||||
public class ServerConsoleConnection
|
public class ServerConsoleConnection
|
||||||
{
|
{
|
||||||
public DateTime AuthenticatedUntil { get; set; }
|
private readonly ServerService ServerService;
|
||||||
public int ServerId { get; set; }
|
private readonly ILogger<ServerConsoleConnection> Logger;
|
||||||
|
private readonly AccessTokenHelper AccessTokenHelper;
|
||||||
|
private readonly IHubContext<ServerConsoleHub> HubContext;
|
||||||
|
|
||||||
|
private int ServerId = -1;
|
||||||
|
private Server Server;
|
||||||
|
private bool IsInitialized = false;
|
||||||
|
private string ConnectionId;
|
||||||
|
|
||||||
|
public ServerConsoleConnection(
|
||||||
|
ServerService serverService,
|
||||||
|
ILogger<ServerConsoleConnection> logger,
|
||||||
|
AccessTokenHelper accessTokenHelper,
|
||||||
|
IHubContext<ServerConsoleHub> hubContext
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ServerService = serverService;
|
||||||
|
Logger = logger;
|
||||||
|
AccessTokenHelper = accessTokenHelper;
|
||||||
|
HubContext = hubContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Initialize(HubCallerContext context) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public async Task Authenticate(HubCallerContext context, string accessToken)
|
||||||
|
{
|
||||||
|
// Validate access token
|
||||||
|
if (!AccessTokenHelper.Process(accessToken, out var accessData))
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Received invalid or expired access token");
|
||||||
|
|
||||||
|
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
|
||||||
|
"Error",
|
||||||
|
"Received invalid or expired access token"
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate access token data
|
||||||
|
if (!accessData.ContainsKey("type") || !accessData.ContainsKey("serverId"))
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Received invalid access token: Required parameters are missing");
|
||||||
|
|
||||||
|
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
|
||||||
|
"Error",
|
||||||
|
"Received invalid access token: Required parameters are missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate access token type
|
||||||
|
var type = accessData["type"].GetString()!;
|
||||||
|
|
||||||
|
if (type != "console")
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Received invalid access token: Invalid type '{type}'", type);
|
||||||
|
|
||||||
|
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
|
||||||
|
"Error",
|
||||||
|
$"Received invalid access token: Invalid type '{type}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverId = accessData["serverId"].GetInt32();
|
||||||
|
|
||||||
|
// Check that the access token isn't or another server
|
||||||
|
if (ServerId != -1 && ServerId == serverId)
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Received invalid access token: Server id not valid for this session. Current server id: {serverId}", ServerId);
|
||||||
|
|
||||||
|
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
|
||||||
|
"Error",
|
||||||
|
$"Received invalid access token: Server id not valid for this session. Current server id: {ServerId}"
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var server = ServerService.GetServer(serverId);
|
||||||
|
|
||||||
|
// Check i the server actually exists
|
||||||
|
if (server == null)
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Received invalid access token: No server found with the requested id");
|
||||||
|
|
||||||
|
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
|
||||||
|
"Error",
|
||||||
|
"Received invalid access token: No server found with the requested id"
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set values
|
||||||
|
Server = server;
|
||||||
|
ServerId = serverId;
|
||||||
|
ConnectionId = context.ConnectionId;
|
||||||
|
|
||||||
|
if(IsInitialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
IsInitialized = true;
|
||||||
|
|
||||||
|
// Setup event handlers
|
||||||
|
Server.StateMachine.OnTransitioned += HandlePowerStateChange;
|
||||||
|
Server.OnTaskAdded += HandleTaskAdded;
|
||||||
|
Server.Console.OnOutput += HandleConsoleOutput;
|
||||||
|
|
||||||
|
Logger.LogTrace("Authenticated and initialized server console connection '{id}'", context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Destroy(HubCallerContext context)
|
||||||
|
{
|
||||||
|
Server.StateMachine.OnTransitioned -= HandlePowerStateChange;
|
||||||
|
Server.OnTaskAdded -= HandleTaskAdded;
|
||||||
|
|
||||||
|
Logger.LogTrace("Destroyed server console connection '{id}'", context.ConnectionId);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Event Handlers
|
||||||
|
|
||||||
|
private async Task HandlePowerStateChange(ServerState serverState)
|
||||||
|
=> await HubContext.Clients.Client(ConnectionId).SendAsync("PowerStateChanged", serverState.ToString());
|
||||||
|
|
||||||
|
private async Task HandleTaskAdded(string task)
|
||||||
|
=> await HubContext.Clients.Client(ConnectionId).SendAsync("TaskNotify", task);
|
||||||
|
|
||||||
|
private async Task HandleConsoleOutput(string line)
|
||||||
|
=> await HubContext.Clients.Client(ConnectionId).SendAsync("ConsoleOutput", line);
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,20 @@ public class ServersController : Controller
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{serverId:int}/logs")]
|
||||||
|
public async Task<ServerLogsResponse> GetLogs([FromRoute] int serverId)
|
||||||
|
{
|
||||||
|
var server = ServerService.GetServer(serverId);
|
||||||
|
|
||||||
|
if (server == null)
|
||||||
|
throw new HttpApiException("No server with this id found", 404);
|
||||||
|
|
||||||
|
return new ServerLogsResponse()
|
||||||
|
{
|
||||||
|
Messages = server.Console.Messages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{serverId:int}/start")]
|
[HttpPost("{serverId:int}/start")]
|
||||||
public async Task Start(int serverId)
|
public async Task Start(int serverId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,12 +17,24 @@ public class ServerConsoleHub : Hub
|
|||||||
ConsoleService = consoleService;
|
ConsoleService = consoleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Connection Handlers
|
||||||
|
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
=> await ConsoleService.InitializeClient(Context);
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
=> await ConsoleService.DestroyClient(Context);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
|
||||||
[HubMethodName("Authenticate")]
|
[HubMethodName("Authenticate")]
|
||||||
public async Task Authenticate(string accessToken)
|
public async Task Authenticate(string accessToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConsoleService.Authenticate(Context, accessToken);
|
await ConsoleService.AuthenticateClient(Context, accessToken);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@@ -30,6 +42,5 @@ public class ServerConsoleHub : Hub
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
#endregion
|
||||||
=> await ConsoleService.OnClientDisconnected(Context);
|
|
||||||
}
|
}
|
||||||
@@ -8,152 +8,61 @@ namespace MoonlightServers.Daemon.Services;
|
|||||||
[Singleton]
|
[Singleton]
|
||||||
public class ServerConsoleService
|
public class ServerConsoleService
|
||||||
{
|
{
|
||||||
private readonly Dictionary<int, ServerConsoleMonitor> Monitors = new();
|
|
||||||
private readonly Dictionary<string, ServerConsoleConnection> Connections = new();
|
|
||||||
|
|
||||||
private readonly ILogger<ServerConsoleService> Logger;
|
private readonly ILogger<ServerConsoleService> Logger;
|
||||||
private readonly AccessTokenHelper AccessTokenHelper;
|
private readonly IServiceProvider ServiceProvider;
|
||||||
private readonly ServerService ServerService;
|
|
||||||
|
|
||||||
private readonly IHubContext<ServerConsoleHub> HubContext;
|
private readonly Dictionary<string, ServerConsoleConnection> Connections = new();
|
||||||
|
|
||||||
public ServerConsoleService(
|
public ServerConsoleService(
|
||||||
ILogger<ServerConsoleService> logger,
|
ILogger<ServerConsoleService> logger,
|
||||||
AccessTokenHelper accessTokenHelper,
|
IServiceProvider serviceProvider
|
||||||
ServerService serverService, IHubContext<ServerConsoleHub> hubContext)
|
)
|
||||||
{
|
{
|
||||||
Logger = logger;
|
Logger = logger;
|
||||||
AccessTokenHelper = accessTokenHelper;
|
ServiceProvider = serviceProvider;
|
||||||
ServerService = serverService;
|
|
||||||
HubContext = hubContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task OnClientDisconnected(HubCallerContext context)
|
public async Task InitializeClient(HubCallerContext context)
|
||||||
{
|
{
|
||||||
ServerConsoleConnection? removedConnection;
|
var connection = new ServerConsoleConnection(
|
||||||
|
ServiceProvider.GetRequiredService<ServerService>(),
|
||||||
|
ServiceProvider.GetRequiredService<ILogger<ServerConsoleConnection>>(),
|
||||||
|
ServiceProvider.GetRequiredService<AccessTokenHelper>(),
|
||||||
|
ServiceProvider.GetRequiredService<IHubContext<ServerConsoleHub>>()
|
||||||
|
);
|
||||||
|
|
||||||
lock (Connections)
|
lock (Connections)
|
||||||
{
|
Connections[context.ConnectionId] = connection;
|
||||||
removedConnection = Connections.GetValueOrDefault(context.ConnectionId);
|
|
||||||
|
|
||||||
if(removedConnection != null)
|
await connection.Initialize(context);
|
||||||
Connections.Remove(context.ConnectionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client never authenticated themselves, nothing to do
|
public async Task AuthenticateClient(HubCallerContext context, string accessToken)
|
||||||
if(removedConnection == null)
|
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
Logger.LogDebug("Authenticated client {id} disconnected", context.ConnectionId);
|
|
||||||
|
|
||||||
// Count remaining clients requesting the same resource
|
|
||||||
int count;
|
|
||||||
|
|
||||||
lock (Connections)
|
|
||||||
{
|
{
|
||||||
count = Connections
|
|
||||||
.Values
|
|
||||||
.Count(x => x.ServerId == removedConnection.ServerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(count > 0)
|
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
ServerConsoleMonitor? monitor;
|
|
||||||
|
|
||||||
lock (Monitors)
|
|
||||||
monitor = Monitors.GetValueOrDefault(removedConnection.ServerId);
|
|
||||||
|
|
||||||
if(monitor == null)
|
|
||||||
return Task.CompletedTask;
|
|
||||||
|
|
||||||
Logger.LogDebug("Destroying console monitor for server {id}", removedConnection.ServerId);
|
|
||||||
monitor.Destroy();
|
|
||||||
|
|
||||||
lock (Monitors)
|
|
||||||
Monitors.Remove(removedConnection.ServerId);
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Authenticate(HubCallerContext context, string accessToken)
|
|
||||||
{
|
|
||||||
// Validate access token
|
|
||||||
if (!AccessTokenHelper.Process(accessToken, out var accessData))
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Received invalid or expired access token. Closing connection");
|
|
||||||
context.Abort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate access token data
|
|
||||||
if (!accessData.ContainsKey("type") || !accessData.ContainsKey("serverId"))
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Received invalid access token: Required parameters are missing. Closing connection");
|
|
||||||
context.Abort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate access token type
|
|
||||||
var type = accessData["type"].GetString()!;
|
|
||||||
|
|
||||||
if (type != "console")
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Received invalid access token: Invalid type '{type}'. Closing connection", type);
|
|
||||||
context.Abort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverId = accessData["serverId"].GetInt32();
|
|
||||||
var server = ServerService.GetServer(serverId);
|
|
||||||
|
|
||||||
if (server == null)
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Received invalid access token: No server found with the requested id. Closing connection");
|
|
||||||
context.Abort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ServerConsoleConnection? connection;
|
ServerConsoleConnection? connection;
|
||||||
|
|
||||||
lock (Connections)
|
lock (Connections)
|
||||||
{
|
connection = Connections.GetValueOrDefault(context.ConnectionId);
|
||||||
connection = Connections
|
|
||||||
.GetValueOrDefault(context.ConnectionId);
|
if(connection == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await connection.Authenticate(context, accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection == null) // If no existing connection has been found, we create a new one
|
public async Task DestroyClient(HubCallerContext context)
|
||||||
{
|
{
|
||||||
connection = new()
|
ServerConsoleConnection? connection;
|
||||||
{
|
|
||||||
ServerId = server.Configuration.Id,
|
|
||||||
AuthenticatedUntil = DateTime.UtcNow.AddMinutes(10)
|
|
||||||
};
|
|
||||||
|
|
||||||
lock (Connections)
|
lock (Connections)
|
||||||
Connections.Add(context.ConnectionId, connection);
|
connection = Connections.GetValueOrDefault(context.ConnectionId);
|
||||||
|
|
||||||
Logger.LogDebug("Connection {id} authenticated successfully", context.ConnectionId);
|
if(connection == null)
|
||||||
}
|
return;
|
||||||
else
|
|
||||||
Logger.LogDebug("Connection {id} re-authenticated successfully", context.ConnectionId);
|
|
||||||
|
|
||||||
ServerConsoleMonitor? monitor;
|
await connection.Destroy(context);
|
||||||
|
|
||||||
lock (Monitors)
|
lock (Connections)
|
||||||
monitor = Monitors.GetValueOrDefault(server.Configuration.Id);
|
Connections.Remove(context.ConnectionId);
|
||||||
|
|
||||||
if (monitor == null)
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Initializing console monitor for server {id}", server.Configuration.Id);
|
|
||||||
|
|
||||||
monitor = new ServerConsoleMonitor(server, HubContext.Clients);
|
|
||||||
monitor.Initialize();
|
|
||||||
|
|
||||||
lock (Monitors)
|
|
||||||
Monitors.Add(server.Configuration.Id, monitor);
|
|
||||||
}
|
|
||||||
|
|
||||||
await HubContext.Groups.AddToGroupAsync(context.ConnectionId, $"server-{server.Configuration.Id}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers;
|
||||||
|
|
||||||
|
public class ServerLogsResponse
|
||||||
|
{
|
||||||
|
public string[] Messages { get; set; }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.11" PrivateAssets="all"/>
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.11" PrivateAssets="all"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Moonlight.Client" Version="2.1.0"/>
|
<PackageReference Include="Moonlight.Client" Version="2.1.0"/>
|
||||||
|
<PackageReference Include="XtermBlazor" Version="2.1.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -21,7 +22,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Helpers\"/>
|
<Folder Include="Helpers\"/>
|
||||||
<Folder Include="Interfaces\"/>
|
<Folder Include="Interfaces\"/>
|
||||||
<Folder Include="wwwroot\"/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
1
MoonlightServers.Frontend/Styles/additions/fonts.css
Normal file
1
MoonlightServers.Frontend/Styles/additions/fonts.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Space+Mono,wght@0,200..900;1,200..900&display=swap');
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@import "additions/animations.css";
|
@import "additions/animations.css";
|
||||||
@import "additions/buttons.css";
|
@import "additions/buttons.css";
|
||||||
@import "additions/cards.css";
|
@import "additions/cards.css";
|
||||||
|
@import "additions/fonts.css";
|
||||||
@import "additions/progress.css";
|
@import "additions/progress.css";
|
||||||
@import "additions/scrollbar.css";
|
@import "additions/scrollbar.css";
|
||||||
@import "additions/loaders.css";
|
@import "additions/loaders.css";
|
||||||
|
|||||||
97
MoonlightServers.Frontend/UI/Components/XtermConsole.razor
Normal file
97
MoonlightServers.Frontend/UI/Components/XtermConsole.razor
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
@using XtermBlazor
|
||||||
|
|
||||||
|
@inject IJSRuntime JsRuntime
|
||||||
|
@inject ILogger<XtermConsole> Logger
|
||||||
|
|
||||||
|
<div class="bg-black rounded-lg p-2">
|
||||||
|
@if (IsInitialized)
|
||||||
|
{
|
||||||
|
<Xterm @ref="Terminal"
|
||||||
|
Addons="Addons"
|
||||||
|
Options="Options"
|
||||||
|
OnFirstRender="HandleFirstRender"/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
[Parameter] public Func<Task>? OnAfterInitialized { get; set; }
|
||||||
|
[Parameter] public Func<Task>? OnFirstRender { get; set; }
|
||||||
|
|
||||||
|
private Xterm Terminal;
|
||||||
|
private bool IsInitialized = false;
|
||||||
|
private bool HadFirstRender = false;
|
||||||
|
|
||||||
|
private readonly Queue<string> MessageCache = new();
|
||||||
|
private readonly HashSet<string> Addons = ["addon-fit"];
|
||||||
|
private readonly TerminalOptions Options = new()
|
||||||
|
{
|
||||||
|
CursorBlink = false,
|
||||||
|
CursorStyle = CursorStyle.Bar,
|
||||||
|
CursorWidth = 1,
|
||||||
|
FontFamily = "Space Mono, monospace",
|
||||||
|
DisableStdin = true,
|
||||||
|
CursorInactiveStyle = CursorInactiveStyle.None,
|
||||||
|
Theme =
|
||||||
|
{
|
||||||
|
Background = "#000000"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if(!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Initialize addons
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JsRuntime.InvokeVoidAsync("moonlightServers.loadAddons");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Logger.LogError("An error occured while initializing addons: {e}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
IsInitialized = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
if(OnAfterInitialized != null)
|
||||||
|
await OnAfterInitialized.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleFirstRender()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Terminal.Addon("addon-fit").InvokeVoidAsync("fit");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Logger.LogError("An error occured while calling addons: {e}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
HadFirstRender = true;
|
||||||
|
|
||||||
|
// Write out cache
|
||||||
|
while (MessageCache.Count > 0)
|
||||||
|
{
|
||||||
|
await Terminal.Write(MessageCache.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OnFirstRender != null)
|
||||||
|
await OnFirstRender.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Write(string content)
|
||||||
|
{
|
||||||
|
// We cache messages here as there is the chance that the console isn't ready for input while receiving write tasks
|
||||||
|
|
||||||
|
if (HadFirstRender)
|
||||||
|
await Terminal.Write(content);
|
||||||
|
else
|
||||||
|
MessageCache.Enqueue(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
@using MoonCore.Exceptions
|
@using MoonCore.Exceptions
|
||||||
@using MoonCore.Helpers
|
@using MoonCore.Helpers
|
||||||
@using MoonlightServers.Shared.Enums
|
@using MoonlightServers.Shared.Enums
|
||||||
|
@using MoonlightServers.Frontend.UI.Components
|
||||||
|
|
||||||
@inject HttpApiClient ApiClient
|
@inject HttpApiClient ApiClient
|
||||||
|
|
||||||
@@ -67,11 +68,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex gap-x-1.5">
|
<div class="flex gap-x-1.5">
|
||||||
<WButton CssClasses="btn btn-primary" OnClick="_ => DoSmth()">
|
<button class="btn btn-success">
|
||||||
<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>
|
||||||
</WButton>
|
</button>
|
||||||
<button type="button" class="btn btn-tertiary">
|
<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>
|
||||||
@@ -85,13 +86,15 @@
|
|||||||
|
|
||||||
<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
|
|
||||||
href="/admin/servers"
|
|
||||||
class="block pb-3 text-gray-400 hover:text-white whitespace-nowrap hover:border-b-2 hover: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/all"
|
href="/admin/servers/all"
|
||||||
class="block pb-3 text-white whitespace-nowrap border-b-2 border-primary-500">Files</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
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
@@ -106,6 +109,10 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 h-44">
|
||||||
|
<XtermConsole @ref="XtermConsole" OnAfterInitialized="OnAfterConsoleInitialized" />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</LazyLoader>
|
</LazyLoader>
|
||||||
|
|
||||||
@@ -116,8 +123,11 @@
|
|||||||
private ServerDetailResponse Server;
|
private ServerDetailResponse Server;
|
||||||
private bool NotFound = false;
|
private bool NotFound = false;
|
||||||
private ServerPowerState PowerState;
|
private ServerPowerState PowerState;
|
||||||
|
private string InitialConsoleMessage; // TODO: When moving to a single component, fail safe when failed to load
|
||||||
|
|
||||||
private string CurrentTask = "";
|
private string CurrentTask = "";
|
||||||
|
private XtermConsole? XtermConsole;
|
||||||
|
|
||||||
private HubConnection ConsoleConnection;
|
private HubConnection ConsoleConnection;
|
||||||
|
|
||||||
private async Task Load(LazyLoader _)
|
private async Task Load(LazyLoader _)
|
||||||
@@ -136,6 +146,16 @@
|
|||||||
|
|
||||||
PowerState = status.PowerState;
|
PowerState = status.PowerState;
|
||||||
|
|
||||||
|
// Load initial messages
|
||||||
|
var initialLogs = await ApiClient.GetJson<ServerLogsResponse>(
|
||||||
|
$"api/servers/{ServerId}/logs"
|
||||||
|
);
|
||||||
|
|
||||||
|
InitialConsoleMessage = "";
|
||||||
|
|
||||||
|
foreach (var message in initialLogs.Messages)
|
||||||
|
InitialConsoleMessage += message;
|
||||||
|
|
||||||
// Load console meta
|
// Load console meta
|
||||||
var consoleDetails = await ApiClient.GetJson<ServerConsoleResponse>(
|
var consoleDetails = await ApiClient.GetJson<ServerConsoleResponse>(
|
||||||
$"api/servers/{ServerId}/console"
|
$"api/servers/{ServerId}/console"
|
||||||
@@ -161,6 +181,14 @@
|
|||||||
await AddTask(Formatter.ConvertCamelCaseToSpaces(task));
|
await AddTask(Formatter.ConvertCamelCaseToSpaces(task));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ConsoleConnection.On<string>("ConsoleOutput", async content =>
|
||||||
|
{
|
||||||
|
if (XtermConsole != null)
|
||||||
|
await XtermConsole.Write(content);
|
||||||
|
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
await ConsoleConnection.StartAsync();
|
await ConsoleConnection.StartAsync();
|
||||||
|
|
||||||
@@ -176,22 +204,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DoSmth()
|
private async Task OnAfterConsoleInitialized()
|
||||||
{
|
{
|
||||||
await AddTask("Creating storage");
|
await XtermConsole!.Write(InitialConsoleMessage);
|
||||||
await Task.Delay(1500);
|
|
||||||
|
|
||||||
await AddTask("Pulling docker image");
|
|
||||||
await Task.Delay(1500);
|
|
||||||
|
|
||||||
await AddTask("Removing container");
|
|
||||||
await Task.Delay(1500);
|
|
||||||
|
|
||||||
await AddTask("Creating container");
|
|
||||||
await Task.Delay(1500);
|
|
||||||
|
|
||||||
await AddTask("Starting container");
|
|
||||||
await Task.Delay(1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddTask(string message)
|
private async Task AddTask(string message)
|
||||||
@@ -201,7 +216,7 @@
|
|||||||
|
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await Task.Delay(2000);
|
await Task.Delay(3000);
|
||||||
|
|
||||||
if (CurrentTask != message)
|
if (CurrentTask != message)
|
||||||
return;
|
return;
|
||||||
|
|||||||
1
MoonlightServers.Frontend/wwwroot/css/XtermBlazor.min.css
vendored
Normal file
1
MoonlightServers.Frontend/wwwroot/css/XtermBlazor.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{border:0;height:0;left:-9999em;margin:0;opacity:0;overflow:hidden;padding:0;position:absolute;resize:none;top:0;white-space:nowrap;width:0;z-index:-5}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;bottom:0;cursor:default;left:0;overflow-y:scroll;position:absolute;right:0;top:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{left:0;position:absolute;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;left:-9999em;line-height:normal;position:absolute;top:0;visibility:hidden}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{bottom:0;color:transparent;left:0;pointer-events:none;position:absolute;right:0;top:0;z-index:10}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{height:1px;left:-9999px;overflow:hidden;position:absolute;width:1px}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{position:absolute;z-index:6}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{pointer-events:none;position:absolute;right:0;top:0;z-index:8}.xterm-decoration-top{position:relative;z-index:2}
|
||||||
9
MoonlightServers.Frontend/wwwroot/js/XtermBlazor.min.js
vendored
Normal file
9
MoonlightServers.Frontend/wwwroot/js/XtermBlazor.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
MoonlightServers.Frontend/wwwroot/js/addon-fit.js
Normal file
2
MoonlightServers.Frontend/wwwroot/js/addon-fit.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
11
MoonlightServers.Frontend/wwwroot/js/moonlightServers.js
Normal file
11
MoonlightServers.Frontend/wwwroot/js/moonlightServers.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
window.moonlightServers = {
|
||||||
|
loadAddons: function () {
|
||||||
|
if(window.moonlightServers.consoleAddonsLoaded)
|
||||||
|
return;
|
||||||
|
|
||||||
|
window.moonlightServers.consoleAddonsLoaded = true;
|
||||||
|
XtermBlazor.registerAddons({"addon-fit": new FitAddon.FitAddon()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.moonlightServers.consoleAddonsLoaded = false;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MoonlightServers.Shared.Http.Responses.Users.Servers;
|
||||||
|
|
||||||
|
public class ServerLogsResponse
|
||||||
|
{
|
||||||
|
public string[] Messages { get; set; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user