Refactored ui. Improved console experience. Added command endpoint

This commit is contained in:
2025-07-18 21:16:52 +02:00
parent f8c11b2dd8
commit 265a4b280b
43 changed files with 479 additions and 149 deletions

View File

@@ -0,0 +1,89 @@
@using System.Text.Json.Serialization
@using Microsoft.Extensions.Logging
@using XtermBlazor
@inherits MoonCore.Blazor.FlyonUi.Modals.Components.BaseModal
@inject IJSRuntime JsRuntime
@inject ILogger<FullScreenModal> Logger
@implements IAsyncDisposable
<div class="bg-black p-2 relative w-full h-[90vh] rounded-lg">
@if (IsInitialized)
{
<Xterm @ref="Terminal"
Addons="Parent.Addons"
Options="Parent.Options"
Class="h-full w-full"
OnFirstRender="HandleFirstRender"/>
}
<div class="absolute top-4 right-4">
<button @onclick="Hide" class="btn btn-error btn-square">
<i class="icon-x text-lg"></i>
</button>
</div>
</div>
@code
{
[Parameter] public XtermConsole Parent { get; set; }
private bool IsInitialized = false;
private bool IsReadyToWrite = false;
private Xterm Terminal;
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);
}
// Subscribe to parent events
Parent.OnWrite += HandleWrite;
IsInitialized = true;
await InvokeAsync(StateHasChanged);
}
private async Task HandleFirstRender()
{
IsReadyToWrite = true;
try
{
await Terminal.Addon("addon-fit").InvokeVoidAsync("fit");
}
catch (Exception e)
{
Logger.LogError("An error occured while calling addons: {e}", e);
}
var outputToWrite = string.Concat(Parent.OutputCache.ToArray());
await Terminal.Write(outputToWrite);
}
private async Task HandleWrite(string content)
{
if (!IsReadyToWrite)
return;
await Terminal.Write(content);
}
public async ValueTask DisposeAsync()
{
Parent.OnWrite -= HandleWrite;
await Terminal.DisposeAsync();
}
}

View File

@@ -18,7 +18,7 @@
<div class="col-span-1">
<div class="card">
<div class="card-header">
Actions
<span class="card-title">Actions</span>
</div>
<div class="card-body">
<div class="flex flex-col gap-y-3">

View File

@@ -8,7 +8,7 @@
@inject NodeService NodeService
@inject ToastService ToastService
@inject ILogger<OverviewNodeUpdate> Logger
@inject ILogger<Overview> Logger
@implements IDisposable
@@ -26,7 +26,7 @@
</p>
<i class="icon-cpu text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
<div class="text-base text-base-content/90">
<span class="truncate">
CPU: @Statistics.Cpu.Model
</span>
@@ -42,7 +42,7 @@
</p>
<i class="icon-memory-stick text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
<div class="text-base text-base-content/90">
Memory
</div>
</div>
@@ -56,7 +56,7 @@
</div>
<i class="icon-shapes text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
<div class="text-base text-base-content/90">
Swap
</div>
</div>
@@ -76,7 +76,7 @@
var percentRounded = Math.Round(usage, 2);
<div class="flex flex-row items-center col-span-1">
<div class="text-sm text-slate-300 me-1.5 grow-0 flex flex-col">
<div class="text-sm text-base-content/90 me-1.5 grow-0 flex flex-col">
<span>#@(i)</span>
</div>
<div class="grow">
@@ -108,7 +108,7 @@
</div>
</div>
</div>
<div class="text-sm text-slate-300 mt-2.5 flex flex-col">
<div class="text-sm text-base-content/90 mt-2.5 flex flex-col">
<div>
Device: <span class="font-semibold">@disk.Device</span> - Mounted at: <span class="font-semibold truncate">@disk.MountPath</span>
</div>
@@ -136,7 +136,7 @@
</p>
<i class="icon-gallery-horizontal-end text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
<div class="text-base text-base-content/90">
Images
</div>
</div>
@@ -148,7 +148,7 @@
</p>
<i class="icon-container text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
<div class="text-base text-base-content/90">
Containers
</div>
</div>
@@ -160,7 +160,7 @@
</p>
<i class="icon-hard-hat text-4xl text-primary"></i>
</div>
<div class="text-base text-slate-300">
<div class="text-base text-base-content/90">
Build Cache
</div>
</div>

View File

@@ -8,8 +8,8 @@
@inject ILogger<ServerCard> Logger
@{
var gradient = "from-base-content/20";
var border = "border-base-content";
var gradient = "from-base-100/20";
var border = "border-base-content/80";
if (IsLoaded && !IsFailed)
{
@@ -20,7 +20,7 @@
ServerState.Starting => "from-warning/20",
ServerState.Stopping => "from-warning/20",
ServerState.Online => "from-success/20",
_ => "from-base-content/20"
_ => "from-base-100"
};
border = Status.State switch
@@ -30,13 +30,13 @@
ServerState.Starting => "border-warning",
ServerState.Stopping => "border-warning",
ServerState.Online => "border-success",
_ => "border-base-content"
_ => "border-base-content/80"
};
}
}
<a href="/servers/@Server.Id"
class="w-full bg-gradient-to-r @gradient to-base-content/75 to-25% px-5 py-3.5 rounded-xl border-l-8 @border">
class="w-full bg-gradient-to-r @gradient to-base-100/75 to-25% px-5 py-3.5 rounded-xl border-l-8 @border">
<div class="grid grid-cols-6">
<div class="flex items-center col-span-6 sm:col-span-2 2xl:col-span-1">
<div class="bg-base-content/10 bg-opacity-45 py-1 px-2 rounded-lg flex items-center">
@@ -54,7 +54,7 @@
Status.State is ServerState.Starting or ServerState.Stopping or ServerState.Online
)
{
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row">
<div>
<i class="icon-cpu"></i>
</div>
@@ -62,7 +62,7 @@
<div class="ms-3">@(Stats.CpuUsage)%</div>
</div>
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row">
<div>
<i class="icon-memory-stick"></i>
</div>
@@ -70,7 +70,7 @@
<div class="ms-3">@(Formatter.FormatSize(Stats.MemoryUsage)) / @(Formatter.FormatSize(ByteConverter.FromMegaBytes(Server.Memory).Bytes))</div>
</div>
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row">
<div>
<i class="icon-hard-drive"></i>
</div>
@@ -82,7 +82,7 @@
{
if (!IsLoaded)
{
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row text-gray-700">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row text-gray-700">
<div>
<i class="icon-loader"></i>
</div>
@@ -92,7 +92,7 @@
}
else if (IsFailed)
{
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row text-error">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row text-error">
<div>
<i class="icon-cable"></i>
</div>
@@ -102,7 +102,7 @@
}
else if (IsLoaded && !IsFailed && Status.State is ServerState.Offline)
{
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row text-error">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row text-error">
<div>
<i class="icon-power-off"></i>
</div>
@@ -112,7 +112,7 @@
}
else if (IsLoaded && !IsFailed && Status.State is ServerState.Installing)
{
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row text-primary">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row text-primary">
<div>
<i class="icon-hammer"></i>
</div>
@@ -127,7 +127,7 @@
@if (Server.Share != null)
{
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row col-span-2">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row col-span-2">
<div>
<i class="icon-share-2"></i>
</div>
@@ -137,7 +137,7 @@
}
else
{
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row">
<div>
<i class="icon-sparkles"></i>
</div>
@@ -145,7 +145,7 @@
<div class="ms-3">@Server.StarName</div>
</div>
<div class="bg-base-content/35 py-1 px-2 rounded-lg flex flex-row">
<div class="bg-base-200/75 py-1 px-2 rounded-lg flex flex-row">
<div>
<i class="icon-database"></i>
</div>

View File

@@ -1,9 +1,15 @@
@using Microsoft.AspNetCore.SignalR.Client
@using MoonlightServers.Frontend.Services
@inherits BaseServerTab
@inject ServerService ServerService
<div class="h-44">
<XtermConsole @ref="XtermConsole" OnAfterInitialized="OnAfterConsoleInitialized"/>
<XtermConsole @ref="XtermConsole"
OnAfterInitialized="OnAfterConsoleInitialized"
CommandHistory="Parent.CommandHistory"
OnCommand="OnCommand"/>
</div>
@code
@@ -16,10 +22,10 @@
HubConnection.On<string>("ConsoleOutput", async content =>
{
if(XtermConsole != null)
if (XtermConsole != null)
await XtermConsole.Write(content);
});
return Task.CompletedTask;
}
@@ -27,4 +33,7 @@
{
await XtermConsole!.Write(InitialConsoleMessage);
}
private async Task OnCommand(string command)
=> await ServerService.RunCommand(Server.Id, command + "\n");
}

View File

@@ -20,7 +20,7 @@
else
{
<WButton CssClasses="btn btn-primary" OnClick="Reinstall">
<i class="align-middle icon-hammer me-1"></i>
<i class="align-middle icon-hammer"></i>
<span class="align-middle">Reinstall</span>
</WButton>
}

View File

@@ -34,7 +34,7 @@
<div class="grid grid-col-1 gap-y-3">
@foreach (var share in Shares)
{
<div class="col-span-1 card card-body px-5 py-3 flex flex-row items-center justify-between">
<div class="col-span-1 card card-body py-3 flex flex-row items-center justify-between">
<div class="flex justify-start font-semibold">
<i class="icon-user-round me-2"></i>
<span>@share.Username</span>

View File

@@ -12,7 +12,7 @@
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
@foreach (var variable in Variables)
{
<div class="sm:col-span-2 card card-body p-5">
<div class="sm:col-span-2 card card-body">
<label class="block text-sm font-medium leading-6 text-base-content">
@variable.Name
</label>

View File

@@ -4,6 +4,7 @@
@using MoonlightServers.Shared.Http.Responses.Admin.NodeAllocations
@using MoonlightServers.Shared.Http.Responses.Admin.Servers
@using MoonCore.Blazor.FlyonUi.Forms
@using MoonlightServers.Frontend.UI.Views.Admin.All
@inject HttpApiClient ApiClient
@@ -12,7 +13,7 @@
<label class="block text-sm font-medium leading-6 text-base-content">Allocations</label>
<div class="mt-2">
<InputMultipleItem TItem="NodeAllocationResponse"
Value="Allocations"
Value="Parent.Allocations"
DisplayField="@(x => $"{x.IpAddress}:{x.Port}")"
SearchField="@(x => $"{x.IpAddress}:{x.Port}")"
ItemSource="Loader">
@@ -25,7 +26,7 @@
{
[Parameter] public UpdateServerRequest Request { get; set; }
[Parameter] public ServerResponse Server { get; set; }
[Parameter] public List<NodeAllocationResponse> Allocations { get; set; }
[Parameter] public Update Parent { get; set; }
private async Task<NodeAllocationResponse[]> Loader()
{

View File

@@ -3,6 +3,7 @@
@using MoonCore.Models
@using Moonlight.Shared.Http.Responses.Admin.Users
@using MoonCore.Blazor.FlyonUi.Forms
@using MoonlightServers.Frontend.UI.Views.Admin.All
@inject HttpApiClient ApiClient
@@ -20,7 +21,7 @@
<InputItem TItem="UserResponse"
DisplayField="@(x => x.Username)"
SearchField="@(x => x.Username)"
@bind-Value="Owner"
@bind-Value="Parent.Owner"
ItemSource="Loader">
</InputItem>
@@ -61,7 +62,7 @@
@code
{
[Parameter] public UpdateServerRequest Request { get; set; }
[Parameter] public UserResponse Owner { get; set; }
[Parameter] public Update Parent { get; set; }
private async Task<UserResponse[]> Loader()
{

View File

@@ -11,7 +11,7 @@
<LazyLoader Load="Load">
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
@foreach (var variable in Variables)
@foreach (var variable in ServerVariables)
{
var reqVariable = Request.Variables.FirstOrDefault(x => x.Key == variable.Key);
var starVariable = StarVariables.FirstOrDefault(x => x.Key == variable.Key);
@@ -46,7 +46,7 @@
[Parameter] public ServerResponse Server { get; set; }
private StarVariableDetailResponse[] StarVariables;
private ServerVariableResponse[] Variables;
private ServerVariableResponse[] ServerVariables;
private async Task Load(LazyLoader _)
{
@@ -56,7 +56,7 @@
)
);
Variables = await PagedData<ServerVariableResponse>.All(async (page, pageSize) =>
ServerVariables = await PagedData<ServerVariableResponse>.All(async (page, pageSize) =>
await ApiClient.GetJson<PagedData<ServerVariableResponse>>(
$"api/admin/servers/{Server.Id}/variables?page={page}&pageSize={pageSize}"
)

View File

@@ -7,7 +7,7 @@
@using MoonlightServers.Shared.Http.Requests.Admin.Stars
@using MoonlightServers.Shared.Models
@inject ILogger<ParseConfigStarUpdate> Logger
@inject ILogger<ParseConfig> Logger
@inject ModalService ModalService
@inject AlertService AlertService
@inject ToastService ToastService

View File

@@ -20,7 +20,7 @@
<LazyLoader @ref="LazyLoader" Load="Load">
<div class="grid sm:grid-cols-2 xl:grid-cols-3 gap-4">
@foreach (var variable in Variables)
@foreach (var variable in CurrentVariables)
{
<div class="col-span-1 card card-body p-2.5">
<div class="flex items-center justify-between">
@@ -48,7 +48,7 @@
{
[Parameter] public StarDetailResponse Star { get; set; }
private StarVariableDetailResponse[] Variables;
private StarVariableDetailResponse[] CurrentVariables;
private LazyLoader LazyLoader;
private async Task Load(LazyLoader arg)
@@ -57,7 +57,7 @@
$"api/admin/servers/stars/{Star.Id}/variables?page=0&pageSize=50"
);
Variables = pagedVariables.Items;
CurrentVariables = pagedVariables.Items;
}
private async Task AddVariable()

View File

@@ -1,31 +1,72 @@
@using System.Collections.Concurrent
@using Microsoft.Extensions.Logging
@using MoonCore.Blazor.FlyonUi.Modals
@using MoonCore.Helpers
@using XtermBlazor
@using MoonCore.Blazor.FlyonUi.Components
@inject IJSRuntime JsRuntime
@inject ModalService ModalService
@inject ILogger<XtermConsole> Logger
<div class="bg-background rounded-lg p-2">
@implements IAsyncDisposable
<div class="bg-black rounded-lg p-2 relative">
@if (IsInitialized)
{
<Xterm @ref="Terminal"
Addons="Addons"
Options="Options"
Class="h-full w-full"
OnFirstRender="HandleFirstRender"/>
}
<div class="flex flex-row w-full mt-1.5">
<input @bind="CommandInput" @onkeyup="HandleKey" type="text" placeholder="Type here..." class="input grow"/>
<WButton OnClick="_ => SubmitCommand()" CssClasses="btn btn-square btn-primary grow-0 ms-1.5">
<i class="icon-send-horizontal text-lg"></i>
</WButton>
</div>
<div class="absolute top-4 right-4">
<div class="flex flex-col gap-y-1.5">
@if (IsPaused)
{
<button @onclick="TogglePause" class="btn btn-primary btn-square">
<i class="icon-play text-lg"></i>
</button>
}
else
{
<button @onclick="TogglePause" class="btn btn-secondary btn-square">
<i class="icon-pause text-lg"></i>
</button>
}
<button @onclick="OpenFullscreen" class="btn btn-secondary btn-square">
<i class="icon-maximize text-lg"></i>
</button>
</div>
</div>
</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;
[Parameter] public bool ShowActions { get; set; } = true;
[Parameter] public bool ShowInput { get; set; } = false;
[Parameter] public int MaxOutputCacheSize { get; set; } = 250;
[Parameter] public Func<string, Task>? OnCommand { get; set; }
private readonly Queue<string> MessageCache = new();
private readonly HashSet<string> Addons = ["addon-fit"];
private readonly TerminalOptions Options = new()
[Parameter] public IList<string> CommandHistory { get; set; } = new ConcurrentList<string>();
public event Func<string, Task>? OnWrite;
private Xterm Terminal;
public HashSet<string> Addons { get; } = ["addon-fit"];
public TerminalOptions Options { get; } = new()
{
CursorBlink = false,
CursorStyle = CursorStyle.Bar,
@@ -36,17 +77,23 @@
Theme =
{
Background = "#000000"
}
},
};
public ConcurrentList<string> OutputCache { get; private set; } = new();
public ConcurrentList<string> WriteQueue { get; private set; } = new();
private int CommandIndex = -1;
private bool IsReadyToWrite = false;
private bool IsPaused = false;
private bool IsInitialized = false;
private string CommandInput = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if(!firstRender)
if (!firstRender)
return;
// Initialize addons
try
{
await JsRuntime.InvokeVoidAsync("moonlightServers.loadAddons");
@@ -59,7 +106,7 @@
IsInitialized = true;
await InvokeAsync(StateHasChanged);
if(OnAfterInitialized != null)
if (OnAfterInitialized != null)
await OnAfterInitialized.Invoke();
}
@@ -74,13 +121,13 @@
Logger.LogError("An error occured while calling addons: {e}", e);
}
HadFirstRender = true;
IsReadyToWrite = true;
// Write out cache
while (MessageCache.Count > 0)
{
await Terminal.Write(MessageCache.Dequeue());
}
// Write queued content since initialisation started
var queueContent = string.Concat(WriteQueue);
WriteQueue.Clear();
await Terminal.Write(queueContent);
if (OnFirstRender != null)
await OnFirstRender.Invoke();
@@ -89,10 +136,103 @@
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);
if (IsReadyToWrite && !IsPaused)
await HandleWrite(content);
else
MessageCache.Enqueue(content);
WriteQueue.Add(content);
}
private async Task HandleWrite(string content)
{
// Update output cache and prune it if required
if (OutputCache.Count > MaxOutputCacheSize)
{
for (var i = 0; i < 50; i++)
OutputCache.RemoveAt(i);
}
OutputCache.Add(content);
// Trigger events
if (OnWrite != null)
await OnWrite.Invoke(content);
// Write in own terminal
await Terminal.Write(content);
}
private async Task OpenFullscreen()
{
await ModalService.Launch<FullScreenModal>(parameters => { parameters["Parent"] = this; }, size: "max-w-none");
}
private async Task TogglePause()
{
IsPaused = !IsPaused;
await InvokeAsync(StateHasChanged);
if (IsPaused)
return;
var queueContent = string.Concat(WriteQueue);
WriteQueue.Clear();
await HandleWrite(queueContent);
}
private async Task SubmitCommand()
{
CommandHistory.Add(CommandInput);
if (OnCommand != null)
await OnCommand.Invoke(CommandInput);
CommandIndex = -1;
CommandInput = "";
await InvokeAsync(StateHasChanged);
}
private async Task HandleKey(KeyboardEventArgs keyboard)
{
switch (keyboard.Code)
{
case "Enter":
await SubmitCommand();
break;
case "ArrowUp" or "ArrowDown":
{
var highestIndex = CommandHistory.Count - 1;
if (CommandIndex == -1)
CommandIndex = highestIndex;
else
{
if (keyboard.Code is "ArrowUp")
CommandIndex++;
else if (keyboard.Code is "ArrowDown")
CommandIndex--;
}
if (CommandIndex > highestIndex)
CommandIndex = highestIndex;
if (CommandIndex < 0)
CommandIndex = 0;
if (CommandIndex <= highestIndex || CommandHistory.Count > 0)
CommandInput = CommandHistory[CommandIndex];
await InvokeAsync(StateHasChanged);
break;
}
}
}
public async ValueTask DisposeAsync()
{
await Terminal.DisposeAsync();
}
}