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:
2026-03-05 10:56:52 +00:00
parent 2d1b48b0d4
commit 7c5dc657dc
54 changed files with 1808 additions and 222 deletions

View File

@@ -0,0 +1,158 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Responses;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Api.Infrastructure.Implementations.NodeToken;
using MoonlightServers.Shared;
using MoonlightServers.Shared.Admin.Nodes;
namespace MoonlightServers.Api.Admin.Nodes;
[ApiController]
[Route("api/admin/servers/nodes")]
public class CrudController : Controller
{
private readonly DatabaseRepository<Node> DatabaseRepository;
private readonly HybridCache Cache;
public CrudController(
DatabaseRepository<Node> databaseRepository,
HybridCache cache
)
{
DatabaseRepository = databaseRepository;
Cache = cache;
}
[HttpGet]
[Authorize(Policy = Permissions.Nodes.View)]
public async Task<ActionResult<PagedData<NodeDto>>> GetAsync(
[FromQuery] int startIndex,
[FromQuery] int length,
[FromQuery] FilterOptions? filterOptions
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = DatabaseRepository
.Query();
// Filters
if (filterOptions != null)
{
foreach (var filterOption in filterOptions.Filters)
{
query = filterOption.Key switch
{
nameof(Node.Name) =>
query.Where(role => EF.Functions.ILike(role.Name, $"%{filterOption.Value}%")),
_ => query
};
}
}
// Pagination
var data = await query
.OrderBy(x => x.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<NodeDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Nodes.View)]
public async Task<ActionResult<NodeDto>> GetAsync([FromRoute] int id)
{
var node = await DatabaseRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (node == null)
return Problem("No node with this id found", statusCode: 404);
return NodeMapper.ToDto(node);
}
[HttpPost]
[Authorize(Policy = Permissions.Nodes.Create)]
public async Task<ActionResult<NodeDto>> CreateAsync([FromBody] CreateNodeDto request)
{
var node = NodeMapper.ToEntity(request);
node.TokenId = GenerateString(10);
node.Token = GenerateString(64);
var finalRole = await DatabaseRepository.AddAsync(node);
return NodeMapper.ToDto(finalRole);
}
[HttpPut("{id:int}")]
[Authorize(Policy = Permissions.Nodes.Edit)]
public async Task<ActionResult<NodeDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateNodeDto request)
{
var node = await DatabaseRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (node == null)
return Problem("No node with this id found", statusCode: 404);
NodeMapper.Merge(node, request);
await DatabaseRepository.UpdateAsync(node);
return NodeMapper.ToDto(node);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Nodes.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var node = await DatabaseRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (node == null)
return Problem("No node with this id found", statusCode: 404);
await DatabaseRepository.RemoveAsync(node);
// Remove cache for node token auth scheme
await Cache.RemoveAsync(string.Format(NodeTokenSchemeHandler.CacheKeyFormat, node.TokenId));
return NoContent();
}
private static string GenerateString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var stringBuilder = new StringBuilder();
var random = new Random();
for (var i = 0; i < length; i++)
{
stringBuilder.Append(chars[random.Next(chars.Length)]);
}
return stringBuilder.ToString();
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Shared;
using MoonlightServers.Shared.Admin.Nodes;
namespace MoonlightServers.Api.Admin.Nodes;
[ApiController]
[Authorize(Policy = Permissions.Nodes.View)]
[Route("api/admin/servers/nodes/{id:int}/health")]
public class HealthController : Controller
{
private readonly DatabaseRepository<Node> DatabaseRepository;
private readonly NodeService NodeService;
private readonly ILogger<HealthController> Logger;
public HealthController(
DatabaseRepository<Node> databaseRepository,
NodeService nodeService,
ILogger<HealthController> logger
)
{
DatabaseRepository = databaseRepository;
NodeService = nodeService;
Logger = logger;
}
[HttpGet]
public async Task<ActionResult<NodeHealthDto>> GetAsync([FromRoute] int id)
{
var node = await DatabaseRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (node == null)
return Problem("No node with this id found", statusCode: 404);
var health = await NodeService.GetHealthAsync(node);
return new NodeHealthDto()
{
StatusCode = health.StatusCode,
RemoteStatusCode = health.Dto?.RemoteStatusCode ?? 0,
IsHealthy = health is { StatusCode: >= 200 and <= 299, Dto.RemoteStatusCode: >= 200 and <= 299 }
};
}
}

View File

@@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Shared.Admin.Nodes;
using Riok.Mapperly.Abstractions;
namespace MoonlightServers.Api.Admin.Nodes;
[Mapper]
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
public static partial class NodeMapper
{
public static partial NodeDto ToDto(Node node);
public static partial IQueryable<NodeDto> ProjectToDto(this IQueryable<Node> nodes);
public static partial Node ToEntity(CreateNodeDto dto);
public static partial void Merge([MappingTarget] Node node, UpdateNodeDto dto);
}

View File

@@ -0,0 +1,132 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.DaemonShared.Http;
using MoonlightServers.DaemonShared.Http.Daemon;
namespace MoonlightServers.Api.Admin.Nodes;
public class NodeService
{
private readonly IHttpClientFactory ClientFactory;
private readonly ILogger<NodeService> Logger;
public NodeService(IHttpClientFactory clientFactory, ILogger<NodeService> logger)
{
ClientFactory = clientFactory;
Logger = logger;
}
public async Task<NodeHealthStatus> GetHealthAsync(Node node)
{
var client = ClientFactory.CreateClient();
var request = CreateBaseRequest(node, HttpMethod.Get, "api/health");
try
{
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
return new NodeHealthStatus((int)response.StatusCode, null);
try
{
var health = await response
.Content
.ReadFromJsonAsync<HealthDto>(SerializationContext.Default.Options);
return new NodeHealthStatus((int)response.StatusCode, health);
}
catch (Exception e)
{
Logger.LogTrace(e, "An unhandled error occured while processing health response of node {id}", node.Id);
return new NodeHealthStatus((int)response.StatusCode, null);
}
}
catch (Exception e)
{
Logger.LogTrace(e, "An error occured while fetching health status of node {id}", node.Id);
return new NodeHealthStatus(0, null);
}
}
private static HttpRequestMessage CreateBaseRequest(
Node node,
[StringSyntax(StringSyntaxAttribute.Uri)]
HttpMethod method,
string endpoint
)
{
var request = new HttpRequestMessage();
request.Headers.Add(HeaderNames.Authorization, node.Token);
request.RequestUri = new Uri(new Uri(node.HttpEndpointUrl), endpoint);
request.Method = method;
return request;
}
private async Task EnsureSuccessAsync(HttpResponseMessage message)
{
if (message.IsSuccessStatusCode)
return;
try
{
var problemDetails = await message.Content.ReadFromJsonAsync<ProblemDetails>(
SerializationContext.Default.Options
);
if (problemDetails == null)
{
// If we cant handle problem details, we handle it natively
message.EnsureSuccessStatusCode();
return;
}
// Parse into exception
throw new NodeException(
problemDetails.Type,
problemDetails.Title,
problemDetails.Status,
problemDetails.Detail,
problemDetails.Errors
);
}
catch (JsonException)
{
// If we cant handle problem details, we handle it natively
message.EnsureSuccessStatusCode();
}
}
}
public record NodeHealthStatus(int StatusCode, HealthDto? Dto);
public class NodeException : Exception
{
public string Type { get; }
public string Title { get; }
public int Status { get; }
public string? Detail { get; }
public Dictionary<string, string[]>? Errors { get; }
public NodeException(
string type,
string title,
int status,
string? detail = null,
Dictionary<string, string[]>? errors = null)
: base(detail ?? title)
{
Type = type;
Title = title;
Status = status;
Detail = detail;
Errors = errors;
}
}