Merge pull request #189 from Moonlight-Panel/AddCustomLayoutServerList

Added custom layout options for the server list
This commit is contained in:
Marcel Baumgartner
2023-06-24 02:35:21 +02:00
committed by GitHub
8 changed files with 1575 additions and 59 deletions

View File

@@ -24,6 +24,8 @@ public class User
public string Country { get; set; } = "";
public string ServerListLayoutJson { get; set; } = "";
// States
public UserStatus Status { get; set; } = UserStatus.Unverified;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.App.Database.Migrations
{
/// <inheritdoc />
public partial class AddedServerListLayoutToUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ServerListLayoutJson",
table: "Users",
type: "longtext",
nullable: false)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ServerListLayoutJson",
table: "Users");
}
}
}

View File

@@ -780,6 +780,10 @@ namespace Moonlight.App.Database.Migrations
b.Property<int>("Rating")
.HasColumnType("int");
b.Property<string>("ServerListLayoutJson")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("State")
.IsRequired()
.HasColumnType("longtext");

View File

@@ -0,0 +1,7 @@
namespace Moonlight.App.Models.Misc;
public class ServerGroup
{
public string Name { get; set; } = "";
public List<string> Servers { get; set; } = new();
}

View File

@@ -103,6 +103,8 @@
<script src="/_content/CurrieTechnologies.Razor.SweetAlert2/sweetAlert2.min.js"></script>
<script src="/_content/Blazor.ContextMenu/blazorContextMenu.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@@shopify/draggable@1.0.0-beta.11/lib/draggable.bundle.js"></script>
<script src="https://www.google.com/recaptcha/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.7.0/lib/xterm-addon-fit.min.js"></script>

View File

@@ -1,13 +1,414 @@
@page "/servers"
@using Moonlight.App.Repositories.Servers
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Models.Misc
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Newtonsoft.Json
@inject ServerRepository ServerRepository
@inject Repository<Server> ServerRepository
@inject Repository<User> UserRepository
@inject SmartTranslateService SmartTranslateService
@inject IServiceScopeFactory ServiceScopeFactory
@inject IJSRuntime JsRuntime
<LazyLoader Load="Load">
<LazyLoader @ref="LazyLoader" Load="Load">
<div class="mx-auto">
<div class="card card-body">
<div class="d-flex justify-content-between">
<span class="badge badge-primary badge-lg px-5 me-4">Beta</span>
@if (EditMode)
{
<div>
<WButton Text="@(SmartTranslateService.Translate("Finish editing layout"))"
CssClasses="btn-secondary"
OnClick="async () => await SetEditMode(false)">
</WButton>
<WButton Text="@(SmartTranslateService.Translate("New group"))"
WorkingText=""
CssClasses="btn-primary"
OnClick="AddGroup">
</WButton>
</div>
}
else
{
<WButton Text="@(SmartTranslateService.Translate("Edit layout"))"
CssClasses="btn-secondary"
OnClick="async () => await SetEditMode(true)">
</WButton>
}
</div>
</div>
@foreach (var group in ServerGroups)
{
@*
<div class="card my-2">
<div class="card-header">
<div class="card-title">
@if (EditMode)
{
<input @bind="group.Name" class="form-control"/>
}
else
{
<span>@(group.Name)</span>
}
</div>
@if (EditMode)
{
<div class="card-toolbar">
<WButton Text="@(SmartTranslateService.Translate("Remove group"))"
WorkingText=""
CssClasses="btn-danger"
OnClick="async () => await RemoveGroup(group)">
</WButton>
</div>
}
</div>
<div class="card-body">
<div class="row min-h-200px draggable-zone" ml-server-group="@(group.Name)">
@foreach (var id in group.Servers)
{
var server = AllServers.First(x => x.Id.ToString() == id);
<div class="col-12 col-md-3 p-3 draggable" ml-server-id="@(server.Id)">
<div class="card bg-secondary">
<div class="card-header">
<div class="card-title">
<h3 class="card-label">@(server.Name)</h3>
</div>
@if (EditMode)
{
<div class="card-toolbar">
<a href="#" class="btn btn-icon btn-sm btn-hover-light-primary draggable-handle">
<i class="bx bx-md bx-move"></i>
</a>
</div>
}
</div>
<div class="card-body">
@if (EditMode)
{
<TL>Hidden in edit mode</TL>
}
else
{
}
</div>
</div>
</div>
}
</div>
</div>
</div>*@
<div class="accordion my-3" id="serverListGroup@(group.GetHashCode())">
<div class="accordion-item">
<h2 class="accordion-header" id="serverListGroup-header@(group.GetHashCode())">
<button class="accordion-button fs-4 fw-semibold collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#serverListGroup-body@(group.GetHashCode())" aria-expanded="false" aria-controls="serverListGroup-body@(group.GetHashCode())">
<div class="d-flex justify-content-between">
<div>
@if (EditMode)
{
<input @bind="group.Name" class="form-control"/>
}
else
{
if (string.IsNullOrEmpty(group.Name))
{
<TL>Unsorted servers</TL>
}
else
{
<span>@(group.Name)</span>
}
}
</div>
<div>
@if (EditMode)
{
<WButton Text="@(SmartTranslateService.Translate("Remove group"))"
WorkingText=""
CssClasses="btn-danger"
OnClick="async () => await RemoveGroup(group)">
</WButton>
}
</div>
</div>
</button>
</h2>
<div id="serverListGroup-body@(group.GetHashCode())" class="accordion-collapse collapse" aria-labelledby="serverListGroup-header@(group.GetHashCode())" data-bs-parent="#serverListGroup">
<div class="accordion-body">
<div class="row min-h-200px draggable-zone" ml-server-group="@(group.Name)">
@foreach (var id in group.Servers)
{
var server = AllServers.First(x => x.Id.ToString() == id);
<div class="col-12 col-md-3 p-3 draggable" ml-server-id="@(server.Id)">
<a class="invisible-a" href="/server/@(server.Uuid)">
<div class="card bg-secondary">
<div class="card-header">
<div class="card-title">
<span class="card-label">@(server.Name)</span>
</div>
@if (EditMode)
{
<div class="card-toolbar">
<a href="#" class="btn btn-icon btn-sm btn-hover-light-primary draggable-handle">
<i class="bx bx-md bx-move"></i>
</a>
</div>
}
</div>
<div class="card-body">
@if (EditMode)
{
<TL>Hidden in edit mode</TL>
}
else
{
<span class="card-text fs-6">
@(Math.Round(server.Memory / 1024D, 2)) GB / @(Math.Round(server.Disk / 1024D, 2)) GB / @(server.Node.Name) <span class="text-gray-700">- @(server.Image.Name)</span>
</span>
<div class="card-text my-1 fs-6 fw-bold">
@(server.Node.Fqdn):@(server.MainAllocation.Port)
</div>
<div class="card-text fs-6">
@if (StatusCache.ContainsKey(server))
{
var status = StatusCache[server];
switch (status)
{
case "offline":
<span class="text-danger">
<TL>Offline</TL>
</span>
break;
case "stopping":
<span class="text-warning">
<TL>Stopping</TL>
</span>
break;
case "starting":
<span class="text-warning">
<TL>Starting</TL>
</span>
break;
case "running":
<span class="text-success">
<TL>Running</TL>
</span>
break;
case "failed":
<span class="text-gray-400">
<TL>Failed</TL>
</span>
break;
default:
<span class="text-danger">
<TL>Offline</TL>
</span>
break;
}
}
else
{
<span class="text-gray-400">
<TL>Loading</TL>
</span>
}
</div>
}
</div>
</div>
</a>
</div>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
</LazyLoader>
@code
{
[CascadingParameter]
public User User { get; set; }
private Server[] AllServers;
private LazyLoader LazyLoader;
private readonly Dictionary<Server, string> StatusCache = new();
private List<ServerGroup> ServerGroups = new();
private bool EditMode = false;
private async Task Load(LazyLoader arg)
{
AllServers = ServerRepository
.Get()
.Include(x => x.Owner)
.Include(x => x.MainAllocation)
.Include(x => x.Node)
.Include(x => x.Image)
.Where(x => x.Owner.Id == User.Id)
.OrderBy(x => x.Name)
.ToArray();
if (string.IsNullOrEmpty(User.ServerListLayoutJson))
{
ServerGroups.Add(new()
{
Name = "",
Servers = AllServers.Select(x => x.Id.ToString()).ToList()
});
}
else
{
ServerGroups = (JsonConvert.DeserializeObject<ServerGroup[]>(
User.ServerListLayoutJson) ?? Array.Empty<ServerGroup>()).ToList();
}
foreach (var server in AllServers)
{
Task.Run(async () =>
{
try
{
using var scope = ServiceScopeFactory.CreateScope();
var serverService = scope.ServiceProvider.GetRequiredService<ServerService>();
AddStatus(server, (await serverService.GetDetails(server)).State);
}
catch (Exception e)
{
AddStatus(server, "failed");
}
});
}
}
private async Task AddGroup()
{
ServerGroups.Insert(0, new()
{
Name = "New group"
});
await InvokeAsync(StateHasChanged);
await JsRuntime.InvokeVoidAsync("moonlight.serverList.init");
}
private async Task RemoveGroup(ServerGroup group)
{
ServerGroups.Remove(group);
await EnsureAllServersInGroups();
await InvokeAsync(StateHasChanged);
await JsRuntime.InvokeVoidAsync("moonlight.serverList.init");
}
private async Task SetEditMode(bool toggle)
{
EditMode = toggle;
await InvokeAsync(StateHasChanged);
if (EditMode)
{
await EnsureAllServersInGroups();
await InvokeAsync(StateHasChanged);
await JsRuntime.InvokeVoidAsync("moonlight.serverList.init");
}
else
{
var json = JsonConvert.SerializeObject(await GetGroupsFromClient());
User.ServerListLayoutJson = json;
UserRepository.Update(User);
await LazyLoader.Reload();
}
}
private async Task<ServerGroup[]> GetGroupsFromClient()
{
var serverGroups = await JsRuntime.InvokeAsync<ServerGroup[]>("moonlight.serverList.getData");
// Check user data to prevent users from doing stupid stuff
foreach (var serverGroup in serverGroups)
{
if (serverGroup.Name.Length > 30)
{
Logger.Verbose("Server list group lenght too long");
return Array.Empty<ServerGroup>();
}
if (serverGroup.Servers.Any(x => AllServers.All(y => y.Id.ToString() != x)))
{
Logger.Verbose("User tried to add a server in his server list which he has no access to");
return Array.Empty<ServerGroup>();
}
}
return serverGroups;
}
private Task EnsureAllServersInGroups()
{
var presentInGroup = new List<Server>();
foreach (var group in ServerGroups)
{
foreach (var id in group.Servers)
presentInGroup.Add(AllServers.First(x => x.Id.ToString() == id));
}
var serversMissing = new List<Server>();
foreach (var server in AllServers)
{
if (presentInGroup.All(x => x.Id != server.Id))
serversMissing.Add(server);
}
if (serversMissing.Any())
{
var defaultGroup = ServerGroups.FirstOrDefault(x => x.Name == "");
if (defaultGroup == null)
{
defaultGroup = new ServerGroup()
{
Name = ""
};
ServerGroups.Add(defaultGroup);
}
foreach (var server in serversMissing)
defaultGroup.Servers.Add(server.Id.ToString());
}
return Task.CompletedTask;
}
private void AddStatus(Server server, string status)
{
lock (StatusCache)
{
StatusCache.Add(server, status);
InvokeAsync(StateHasChanged);
}
}
}
@*
@if (AllServers.Any())
{
if (UseSortedServerView)
@@ -207,57 +608,4 @@ else
</div>
</div>
</div>
</LazyLoader>
@code
{
[CascadingParameter]
public User User { get; set; }
private Server[] AllServers;
private readonly Dictionary<Server, string> StatusCache = new();
private bool UseSortedServerView = false;
private Task Load(LazyLoader arg)
{
AllServers = ServerRepository
.Get()
.Include(x => x.Owner)
.Include(x => x.MainAllocation)
.Include(x => x.Node)
.Include(x => x.Image)
.Where(x => x.Owner.Id == User.Id)
.OrderBy(x => x.Name)
.ToArray();
foreach (var server in AllServers)
{
Task.Run(async () =>
{
try
{
using var scope = ServiceScopeFactory.CreateScope();
var serverService = scope.ServiceProvider.GetRequiredService<ServerService>();
AddStatus(server, (await serverService.GetDetails(server)).State);
}
catch (Exception e)
{
AddStatus(server, "failed");
}
});
}
return Task.CompletedTask;
}
private void AddStatus(Server server, string status)
{
lock (StatusCache)
{
StatusCache.Add(server, status);
InvokeAsync(StateHasChanged);
}
}
}
*@

View File

@@ -355,8 +355,6 @@
{
// filter here what key events should be sent to moonlight
console.log(event);
if(event.code === "KeyS" && event.ctrlKey)
{
event.preventDefault();
@@ -370,5 +368,54 @@
{
window.removeEventListener('keydown', moonlight.keyListener.listener);
}
},
serverList: {
init: function ()
{
if(moonlight.serverList.Swappable)
{
moonlight.serverList.Swappable.destroy();
}
let containers = document.querySelectorAll(".draggable-zone");
if (containers.length !== 0)
{
moonlight.serverList.Swappable = new Draggable.Sortable(containers, {
draggable: ".draggable",
handle: ".draggable .draggable-handle",
mirror: {
//appendTo: selector,
appendTo: "body",
constrainDimensions: true
}
});
}
},
getData: function ()
{
let groups = new Array();
let groupElements = document.querySelectorAll('[ml-server-group]');
groupElements.forEach(groupElement => {
let group = new Object();
group.name = groupElement.attributes.getNamedItem("ml-server-group").value;
let servers = new Array();
let serverElements = groupElement.querySelectorAll("[ml-server-id]");
serverElements.forEach(serverElement => {
let id = serverElement.attributes.getNamedItem("ml-server-id").value;
servers.push(id);
});
group.servers = servers;
groups.push(group);
});
return groups;
}
}
};