Implemented node crud and status health check. Added daemon status health endpoint. Refactored project structure. Added sidebar items and ui views
This commit is contained in:
104
MoonlightServers.Frontend/Admin/Nodes/Create.razor
Normal file
104
MoonlightServers.Frontend/Admin/Nodes/Create.razor
Normal file
@@ -0,0 +1,104 @@
|
||||
@page "/admin/servers/nodes/create"
|
||||
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Helpers
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Nodes
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="formContext">
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Create Node</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Create a new node
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/servers/nodes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<SubmitButton>
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<Field>
|
||||
<FieldLabel for="nodeName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="nodeName"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="nodeHttpEndpoint">HTTP Endpoint</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.HttpEndpointUrl"
|
||||
id="nodeHttpEndpoint"
|
||||
placeholder="http://example.com:8080"/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</EnhancedEditForm>
|
||||
|
||||
@code
|
||||
{
|
||||
private CreateNodeDto Request = new();
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PostAsJsonAsync(
|
||||
"/api/admin/servers/nodes",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Node Creation",
|
||||
$"Successfully created node {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/servers/nodes");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
154
MoonlightServers.Frontend/Admin/Nodes/Edit.razor
Normal file
154
MoonlightServers.Frontend/Admin/Nodes/Edit.razor
Normal file
@@ -0,0 +1,154 @@
|
||||
@page "/admin/servers/nodes/{Id:int}"
|
||||
|
||||
@using System.Net
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Frontend.Helpers
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Nodes
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Cards
|
||||
@using ShadcnBlazor.Emptys
|
||||
@using ShadcnBlazor.Extras.Common
|
||||
@using ShadcnBlazor.Extras.Forms
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Fields
|
||||
@using ShadcnBlazor.Inputs
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject NavigationManager Navigation
|
||||
@inject ToastService ToastService
|
||||
|
||||
<LazyLoader Load="LoadAsync">
|
||||
@if (Node == null)
|
||||
{
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia Variant="EmptyMediaVariant.Icon">
|
||||
<SearchIcon/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Node not found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
A node with this id cannot be found
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EnhancedEditForm Model="Request" OnValidSubmit="OnSubmitAsync" Context="formContext">
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Update Node</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Update node @Node.Name
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button Variant="ButtonVariant.Secondary">
|
||||
<Slot>
|
||||
<a href="/admin/servers/nodes" @attributes="context">
|
||||
<ChevronLeftIcon/>
|
||||
Back
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
<SubmitButton>
|
||||
<CheckIcon/>
|
||||
Continue
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<FieldGroup>
|
||||
<FormValidationSummary/>
|
||||
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<Field>
|
||||
<FieldLabel for="nodeName">Name</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.Name"
|
||||
id="nodeName"/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="nodeHttpEndpoint">HTTP Endpoint</FieldLabel>
|
||||
<TextInputField
|
||||
@bind-Value="Request.HttpEndpointUrl"
|
||||
id="nodeHttpEndpoint"
|
||||
placeholder="http://example.com:8080"/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</EnhancedEditForm>
|
||||
}
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private UpdateNodeDto Request;
|
||||
private NodeDto? Node;
|
||||
|
||||
private async Task LoadAsync(LazyLoader _)
|
||||
{
|
||||
var response = await HttpClient.GetAsync($"api/admin/servers/nodes/{Id}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if(response.StatusCode == HttpStatusCode.NotFound)
|
||||
return;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return;
|
||||
}
|
||||
|
||||
Node = await response.Content.ReadFromJsonAsync<NodeDto>(SerializationContext.Default.Options);
|
||||
|
||||
if(Node == null)
|
||||
return;
|
||||
|
||||
Request = new UpdateNodeDto()
|
||||
{
|
||||
Name = Node.Name,
|
||||
HttpEndpointUrl = Node.HttpEndpointUrl
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore)
|
||||
{
|
||||
var response = await HttpClient.PutAsJsonAsync(
|
||||
$"/api/admin/servers/nodes/{Id}",
|
||||
Request,
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Node Update",
|
||||
$"Successfully updated node {Request.Name}"
|
||||
);
|
||||
|
||||
Navigation.NavigateTo("/admin/servers/nodes");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
109
MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor
Normal file
109
MoonlightServers.Frontend/Admin/Nodes/NodeHealthDisplay.razor
Normal file
@@ -0,0 +1,109 @@
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using MoonlightServers.Shared.Admin.Nodes
|
||||
@using ShadcnBlazor.Tooltips
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject ILogger<NodeHealthDisplay> Logger
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<span class="text-muted-foreground">Loading</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IsHealthy)
|
||||
{
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Slot>
|
||||
<span class="text-green-400" @attributes="context">Healthy</span>
|
||||
</Slot>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@TooltipText
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Slot>
|
||||
<span class="text-destructive" @attributes="context">Unhealthy</span>
|
||||
</Slot>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@TooltipText
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
}
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public NodeDto Node { get; set; }
|
||||
|
||||
private bool IsLoading = true;
|
||||
|
||||
private string TooltipText = "An unknown error has occured. Check logs";
|
||||
private bool IsHealthy;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await HttpClient.GetFromJsonAsync<NodeHealthDto>($"api/admin/servers/nodes/{Node.Id}/health");
|
||||
|
||||
if(result == null)
|
||||
return;
|
||||
|
||||
IsHealthy = result.IsHealthy;
|
||||
|
||||
if (IsHealthy)
|
||||
TooltipText = "Version: v2.1.0"; // TODO: Add version loading
|
||||
else
|
||||
{
|
||||
if (result.StatusCode != 0)
|
||||
{
|
||||
if (result.StatusCode is >= 200 and <= 299)
|
||||
{
|
||||
if (result.RemoteStatusCode != 0)
|
||||
{
|
||||
TooltipText = result.RemoteStatusCode switch
|
||||
{
|
||||
401 => "Daemon is unable to authenticate against the panel",
|
||||
404 => "Daemon is unable to request the panel's endpoint",
|
||||
500 => "Panel encountered an internal server error",
|
||||
_ => $"Panel returned {result.RemoteStatusCode}"
|
||||
};
|
||||
}
|
||||
else
|
||||
TooltipText = "Daemon is unable to reach the panel";
|
||||
}
|
||||
else
|
||||
{
|
||||
TooltipText = result.StatusCode switch
|
||||
{
|
||||
401 => "Panel is unable to authenticate against the node",
|
||||
404 => "Panel is unable to request the daemon's endpoint",
|
||||
500 => "Daemon encountered an internal server error",
|
||||
_ => $"Daemon returned {result.StatusCode}"
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
TooltipText = "Moonlight is unable to reach the node";
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e, "An unhandled error occured while fetching the node health status");
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
158
MoonlightServers.Frontend/Admin/Nodes/Overview.razor
Normal file
158
MoonlightServers.Frontend/Admin/Nodes/Overview.razor
Normal file
@@ -0,0 +1,158 @@
|
||||
@page "/admin/servers/nodes"
|
||||
|
||||
@using LucideBlazor
|
||||
@using Moonlight.Shared.Http.Requests
|
||||
@using Moonlight.Shared.Http.Responses
|
||||
@using MoonlightServers.Shared
|
||||
@using MoonlightServers.Shared.Admin.Nodes
|
||||
@using ShadcnBlazor.DataGrids
|
||||
@using ShadcnBlazor.Buttons
|
||||
@using ShadcnBlazor.Dropdowns
|
||||
@using ShadcnBlazor.Extras.AlertDialogs
|
||||
@using ShadcnBlazor.Extras.Dialogs
|
||||
@using ShadcnBlazor.Extras.Toasts
|
||||
@using ShadcnBlazor.Tabels
|
||||
|
||||
@inject HttpClient HttpClient
|
||||
@inject AlertDialogService AlertDialogService
|
||||
@inject DialogService DialogService
|
||||
@inject ToastService ToastService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IAuthorizationService AuthorizationService
|
||||
|
||||
<div class="flex flex-row justify-between mt-5">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl font-semibold">Nodes</h1>
|
||||
<div class="text-muted-foreground">
|
||||
Manage nodes
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-1.5">
|
||||
<Button>
|
||||
<Slot Context="buttonCtx">
|
||||
<a @attributes="buttonCtx" href="/admin/servers/nodes/create"
|
||||
data-disabled="@(!CreateAccess.Succeeded)">
|
||||
<PlusIcon/>
|
||||
Create
|
||||
</a>
|
||||
</Slot>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="NodeDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<PropertyColumn Field="u => u.Id"/>
|
||||
<TemplateColumn IsFilterable="true" Identifier="@nameof(NodeDto.Name)" Title="Name">
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<a class="text-primary" href="#"
|
||||
@onclick="() => Edit(context)" @onclick:preventDefault>
|
||||
@context.Name
|
||||
</a>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<TemplateColumn IsFilterable="false" Title="Status">
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<NodeHealthDisplay Node="context" />
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
<PropertyColumn Title="HTTP Endpoint"
|
||||
Identifier="@nameof(NodeDto.HttpEndpointUrl)"
|
||||
Field="u => u.HttpEndpointUrl"/>
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<TableCell>
|
||||
<div class="flex flex-row items-center justify-end me-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Slot Context="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
|
||||
@attributes="dropdownSlot">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</Slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => Edit(context)"
|
||||
Disabled="@(!EditAccess.Succeeded)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive"
|
||||
Disabled="@(!DeleteAccess.Succeeded)">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</CellTemplate>
|
||||
</TemplateColumn>
|
||||
</DataGrid>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
|
||||
|
||||
private DataGrid<NodeDto> Grid;
|
||||
|
||||
private AuthorizationResult EditAccess;
|
||||
private AuthorizationResult DeleteAccess;
|
||||
private AuthorizationResult CreateAccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthState;
|
||||
|
||||
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.Edit);
|
||||
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.Delete);
|
||||
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.Create);
|
||||
}
|
||||
|
||||
private async Task<DataGridResponse<NodeDto>> LoadAsync(DataGridRequest<NodeDto> request)
|
||||
{
|
||||
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
|
||||
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<NodeDto>>(
|
||||
$"api/admin/servers/nodes{query}&filterOptions={filterOptions}",
|
||||
SerializationContext.Default.Options
|
||||
);
|
||||
|
||||
return new DataGridResponse<NodeDto>(response!.Data, response.TotalLength);
|
||||
}
|
||||
|
||||
private void Edit(NodeDto context) => NavigationManager.NavigateTo($"/admin/servers/nodes/{context.Id}");
|
||||
|
||||
private async Task DeleteAsync(NodeDto context)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
"Node Deletion",
|
||||
$"Do you really want to delete the node {context.Name}? This cannot be undone.",
|
||||
async () =>
|
||||
{
|
||||
var response = await HttpClient.DeleteAsync($"api/admin/servers/nodes/{context.Id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await Grid.RefreshAsync();
|
||||
|
||||
await ToastService.SuccessAsync(
|
||||
"Node Deletion",
|
||||
$"Successfully deleted node {context.Name}"
|
||||
);
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user