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:
@@ -9,15 +9,6 @@
|
|||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "https://localhost:7240;http://localhost:5031",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
MoonlightServers.Api/Admin/Nodes/CrudController.cs
Normal file
158
MoonlightServers.Api/Admin/Nodes/CrudController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
MoonlightServers.Api/Admin/Nodes/HealthController.cs
Normal file
51
MoonlightServers.Api/Admin/Nodes/HealthController.cs
Normal 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 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
MoonlightServers.Api/Admin/Nodes/NodeMapper.cs
Normal file
17
MoonlightServers.Api/Admin/Nodes/NodeMapper.cs
Normal 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);
|
||||||
|
}
|
||||||
132
MoonlightServers.Api/Admin/Nodes/NodeService.cs
Normal file
132
MoonlightServers.Api/Admin/Nodes/NodeService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using MoonlightServers.Shared.Http.Requests;
|
|
||||||
using MoonlightServers.Shared.Http.Responses;
|
|
||||||
|
|
||||||
namespace MoonlightServers.Api.Http.Controllers;
|
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/form")]
|
|
||||||
public class FormController : Controller
|
|
||||||
{
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<ActionResult<FormResultDto>> PostAsync([FromBody] FormSubmitDto dto)
|
|
||||||
{
|
|
||||||
return new FormResultDto()
|
|
||||||
{
|
|
||||||
Result = dto.TextField.Replace(" ", string.Empty)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace MoonlightServers.Api.Infrastructure.Configuration;
|
||||||
|
|
||||||
|
public class NodeTokenOptions
|
||||||
|
{
|
||||||
|
public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
|
||||||
|
}
|
||||||
38
MoonlightServers.Api/Infrastructure/Database/DataContext.cs
Normal file
38
MoonlightServers.Api/Infrastructure/Database/DataContext.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moonlight.Api.Configuration;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database.Entities;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Database;
|
||||||
|
|
||||||
|
public class DataContext : DbContext
|
||||||
|
{
|
||||||
|
public DbSet<Node> Nodes { get; set; }
|
||||||
|
|
||||||
|
private readonly IOptions<DatabaseOptions> Options;
|
||||||
|
public DataContext(IOptions<DatabaseOptions> options)
|
||||||
|
{
|
||||||
|
Options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
if (optionsBuilder.IsConfigured)
|
||||||
|
return;
|
||||||
|
|
||||||
|
optionsBuilder.UseNpgsql(
|
||||||
|
$"Host={Options.Value.Host};" +
|
||||||
|
$"Port={Options.Value.Port};" +
|
||||||
|
$"Username={Options.Value.Username};" +
|
||||||
|
$"Password={Options.Value.Password};" +
|
||||||
|
$"Database={Options.Value.Database}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema("servers");
|
||||||
|
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database.Interfaces;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Database;
|
||||||
|
|
||||||
|
public class DatabaseRepository<T> where T : class
|
||||||
|
{
|
||||||
|
private readonly DataContext DataContext;
|
||||||
|
private readonly DbSet<T> Set;
|
||||||
|
|
||||||
|
public DatabaseRepository(DataContext dataContext)
|
||||||
|
{
|
||||||
|
DataContext = dataContext;
|
||||||
|
Set = DataContext.Set<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IQueryable<T> Query() => Set;
|
||||||
|
|
||||||
|
public async Task<T> AddAsync(T entity)
|
||||||
|
{
|
||||||
|
if (entity is IActionTimestamps actionTimestamps)
|
||||||
|
{
|
||||||
|
actionTimestamps.CreatedAt = DateTimeOffset.UtcNow;
|
||||||
|
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
var final = Set.Add(entity);
|
||||||
|
await DataContext.SaveChangesAsync();
|
||||||
|
return final.Entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(T entity)
|
||||||
|
{
|
||||||
|
if (entity is IActionTimestamps actionTimestamps)
|
||||||
|
actionTimestamps.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
Set.Update(entity);
|
||||||
|
await DataContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAsync(T entity)
|
||||||
|
{
|
||||||
|
Set.Remove(entity);
|
||||||
|
await DataContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Database;
|
||||||
|
|
||||||
|
public class DbMigrationService : IHostedLifecycleService
|
||||||
|
{
|
||||||
|
private readonly ILogger<DbMigrationService> Logger;
|
||||||
|
private readonly IServiceProvider ServiceProvider;
|
||||||
|
|
||||||
|
public DbMigrationService(ILogger<DbMigrationService> logger, IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
Logger = logger;
|
||||||
|
ServiceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartingAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Logger.LogTrace("Checking for pending migrations");
|
||||||
|
|
||||||
|
await using var scope = ServiceProvider.CreateAsyncScope();
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||||
|
|
||||||
|
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||||
|
var migrationNames = pendingMigrations.ToArray();
|
||||||
|
|
||||||
|
if (migrationNames.Length == 0)
|
||||||
|
{
|
||||||
|
Logger.LogDebug("No pending migrations found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("Pending migrations: {names}", string.Join(", ", migrationNames));
|
||||||
|
Logger.LogInformation("Migration started");
|
||||||
|
|
||||||
|
await context.Database.MigrateAsync(cancellationToken);
|
||||||
|
|
||||||
|
Logger.LogInformation("Migration complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database.Interfaces;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Database.Entities;
|
||||||
|
|
||||||
|
public class Node : IActionTimestamps
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string HttpEndpointUrl { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(10)]
|
||||||
|
public string TokenId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string Token { get; set; }
|
||||||
|
|
||||||
|
// Action timestamps
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace MoonlightServers.Api.Infrastructure.Database.Interfaces;
|
||||||
|
|
||||||
|
internal interface IActionTimestamps
|
||||||
|
{
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(DataContext))]
|
||||||
|
[Migration("20260305104238_AddedBasicNodeEntity")]
|
||||||
|
partial class AddedBasicNodeEntity
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("servers")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.1")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Node", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("HttpEndpointUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Nodes", "servers");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddedBasicNodeEntity : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: "servers");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Nodes",
|
||||||
|
schema: "servers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
HttpEndpointUrl = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
TokenId = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Nodes", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Nodes",
|
||||||
|
schema: "servers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(DataContext))]
|
||||||
|
partial class DataContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("servers")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.1")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Node", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("HttpEndpointUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Nodes", "servers");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Hybrid;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database.Entities;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Implementations.NodeToken;
|
||||||
|
|
||||||
|
public class NodeTokenSchemeHandler : AuthenticationHandler<NodeTokenSchemeOptions>
|
||||||
|
{
|
||||||
|
public const string SchemeName = "MoonlightServers.NodeToken";
|
||||||
|
public const string CacheKeyFormat = $"MoonlightServers.{nameof(NodeTokenSchemeHandler)}.{{0}}";
|
||||||
|
|
||||||
|
private readonly DatabaseRepository<Node> DatabaseRepository;
|
||||||
|
private readonly HybridCache Cache;
|
||||||
|
|
||||||
|
public NodeTokenSchemeHandler(
|
||||||
|
IOptionsMonitor<NodeTokenSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
DatabaseRepository<Node> databaseRepository,
|
||||||
|
HybridCache cache
|
||||||
|
) : base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
DatabaseRepository = databaseRepository;
|
||||||
|
Cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
// Basic format validation
|
||||||
|
|
||||||
|
if (!Context.Request.Headers.TryGetValue(HeaderNames.Authorization, out var authHeaderValues))
|
||||||
|
return AuthenticateResult.Fail("No authorization header present");
|
||||||
|
|
||||||
|
if (authHeaderValues.Count != 1)
|
||||||
|
return AuthenticateResult.Fail("No authorization value present");
|
||||||
|
|
||||||
|
var authHeaderValue = authHeaderValues[0];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(authHeaderValue))
|
||||||
|
return AuthenticateResult.Fail("No authorization value present");
|
||||||
|
|
||||||
|
var authHeaderParts = authHeaderValue.Split(
|
||||||
|
' ',
|
||||||
|
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate parts
|
||||||
|
if (authHeaderParts.Length < 2)
|
||||||
|
return AuthenticateResult.Fail("Malformed authorization header");
|
||||||
|
|
||||||
|
var tokenId = authHeaderParts[0];
|
||||||
|
var token = authHeaderParts[1];
|
||||||
|
|
||||||
|
if (tokenId.Length != 10 && token.Length != 64)
|
||||||
|
return AuthenticateResult.Fail("Malformed authorization header");
|
||||||
|
|
||||||
|
// Real validation
|
||||||
|
|
||||||
|
var cacheKey = string.Format(CacheKeyFormat, tokenId);
|
||||||
|
|
||||||
|
var session = await Cache.GetOrCreateAsync<NodeTokenSession?>(cacheKey, async cancellationToken =>
|
||||||
|
{
|
||||||
|
return await DatabaseRepository
|
||||||
|
.Query()
|
||||||
|
.Where(x => x.TokenId == tokenId)
|
||||||
|
.Select(x => new NodeTokenSession(x.Id, x.Token))
|
||||||
|
.FirstOrDefaultAsync(cancellationToken: cancellationToken);
|
||||||
|
},
|
||||||
|
new HybridCacheEntryOptions()
|
||||||
|
{
|
||||||
|
LocalCacheExpiration = Options.LookupCacheL1Expiry,
|
||||||
|
Expiration = Options.LookupCacheL2Expiry
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if(session == null || token != session.Token)
|
||||||
|
return AuthenticateResult.Fail("Invalid authorization header");
|
||||||
|
|
||||||
|
// All checks have passed, create auth ticket
|
||||||
|
return AuthenticateResult.Success(new AuthenticationTicket(
|
||||||
|
new ClaimsPrincipal(
|
||||||
|
new ClaimsIdentity(
|
||||||
|
[
|
||||||
|
new Claim("NodeId", session.Id.ToString())
|
||||||
|
],
|
||||||
|
SchemeName
|
||||||
|
)
|
||||||
|
),
|
||||||
|
SchemeName
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NodeTokenSession(int Id, string Token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Infrastructure.Implementations.NodeToken;
|
||||||
|
|
||||||
|
public class NodeTokenSchemeOptions : AuthenticationSchemeOptions
|
||||||
|
{
|
||||||
|
public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
|
||||||
|
}
|
||||||
@@ -18,18 +18,11 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Configuration\"/>
|
<Folder Include="Client\" />
|
||||||
<Folder Include="Database\Entities\"/>
|
|
||||||
<Folder Include="Database\Migrations\"/>
|
|
||||||
<Folder Include="Helpers\"/>
|
|
||||||
<Folder Include="Implementations\"/>
|
|
||||||
<Folder Include="Interfaces\"/>
|
|
||||||
<Folder Include="Mappers\"/>
|
|
||||||
<Folder Include="Models\"/>
|
|
||||||
<Folder Include="Services\"/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MoonlightServers.DaemonShared\MoonlightServers.DaemonShared.csproj" />
|
||||||
<ProjectReference Include="..\MoonlightServers.Shared\MoonlightServers.Shared.csproj" />
|
<ProjectReference Include="..\MoonlightServers.Shared\MoonlightServers.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
14
MoonlightServers.Api/Remote/Nodes/PingController.cs
Normal file
14
MoonlightServers.Api/Remote/Nodes/PingController.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Implementations.NodeToken;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Api.Remote.Nodes;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/remote/servers/nodes/ping")]
|
||||||
|
[Authorize(AuthenticationSchemes = NodeTokenSchemeHandler.SchemeName)]
|
||||||
|
public class PingController : Controller
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult Get() => NoContent();
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Moonlight.Api;
|
using Moonlight.Api;
|
||||||
|
using MoonlightServers.Api.Admin.Nodes;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Configuration;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Database;
|
||||||
|
using MoonlightServers.Api.Infrastructure.Implementations.NodeToken;
|
||||||
using SimplePlugin.Abstractions;
|
using SimplePlugin.Abstractions;
|
||||||
using SerializationContext = MoonlightServers.Shared.SerializationContext;
|
using SerializationContext = MoonlightServers.Shared.SerializationContext;
|
||||||
|
|
||||||
@@ -17,5 +23,28 @@ public class Startup : MoonlightPlugin
|
|||||||
{
|
{
|
||||||
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
|
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddScoped(typeof(DatabaseRepository<>));
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<DataContext>();
|
||||||
|
builder.Services.AddHostedService<DbMigrationService>();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<NodeService>();
|
||||||
|
|
||||||
|
var nodeTokenOptions = new NodeTokenOptions();
|
||||||
|
builder.Configuration.Bind("Moonlight:Servers:NodeToken", nodeTokenOptions);
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddAuthentication()
|
||||||
|
.AddScheme<NodeTokenSchemeOptions, NodeTokenSchemeHandler>(NodeTokenSchemeHandler.SchemeName, options =>
|
||||||
|
{
|
||||||
|
options.LookupCacheL1Expiry = nodeTokenOptions.LookupCacheL1Expiry;
|
||||||
|
options.LookupCacheL2Expiry = nodeTokenOptions.LookupCacheL2Expiry;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Logging.AddFilter(
|
||||||
|
"MoonlightServers.Api.Infrastructure.Implementations.NodeToken.NodeTokenSchemeHandler",
|
||||||
|
LogLevel.Warning
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
8
MoonlightServers.Daemon/Configuration/RemoteOptions.cs
Normal file
8
MoonlightServers.Daemon/Configuration/RemoteOptions.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MoonlightServers.Daemon.Configuration;
|
||||||
|
|
||||||
|
public class RemoteOptions
|
||||||
|
{
|
||||||
|
public string EndpointUrl { get; set; }
|
||||||
|
public string TokenId { get; set; }
|
||||||
|
public string Token { get; set; }
|
||||||
|
}
|
||||||
30
MoonlightServers.Daemon/Http/Controllers/HealthController.cs
Normal file
30
MoonlightServers.Daemon/Http/Controllers/HealthController.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoonlightServers.Daemon.Services;
|
||||||
|
using MoonlightServers.DaemonShared.Http.Daemon;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Daemon.Http.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/health")]
|
||||||
|
public class HealthController : Controller
|
||||||
|
{
|
||||||
|
private readonly RemoteService RemoteService;
|
||||||
|
|
||||||
|
public HealthController(RemoteService remoteService)
|
||||||
|
{
|
||||||
|
RemoteService = remoteService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<HealthDto>> GetAsync()
|
||||||
|
{
|
||||||
|
var remoteStatusCode = await RemoteService.CheckReachabilityAsync();
|
||||||
|
|
||||||
|
return new HealthDto()
|
||||||
|
{
|
||||||
|
RemoteStatusCode = remoteStatusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Daemon.Implementations.TokenScheme;
|
||||||
|
|
||||||
|
public class TokenSchemeHandler : AuthenticationHandler<TokenSchemeOptions>
|
||||||
|
{
|
||||||
|
public const string SchemeName = "MoonlightServers.Token";
|
||||||
|
|
||||||
|
public TokenSchemeHandler(
|
||||||
|
IOptionsMonitor<TokenSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder
|
||||||
|
) : base(options, logger, encoder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
if (!Context.Request.Headers.TryGetValue(HeaderNames.Authorization, out var authHeaderValues))
|
||||||
|
return Task.FromResult(AuthenticateResult.Fail("No authorization header present"));
|
||||||
|
|
||||||
|
if (authHeaderValues.Count != 1)
|
||||||
|
return Task.FromResult(AuthenticateResult.Fail("No authorization value present"));
|
||||||
|
|
||||||
|
var authHeaderValue = authHeaderValues[0];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(authHeaderValue))
|
||||||
|
return Task.FromResult(AuthenticateResult.Fail("No authorization value present"));
|
||||||
|
|
||||||
|
if (authHeaderValue != Options.Token)
|
||||||
|
return Task.FromResult(AuthenticateResult.Fail("Invalid token provided"));
|
||||||
|
|
||||||
|
return Task.FromResult(
|
||||||
|
AuthenticateResult.Success(new AuthenticationTicket(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity([], nameof(TokenSchemeHandler))),
|
||||||
|
nameof(TokenSchemeHandler)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Daemon.Implementations.TokenScheme;
|
||||||
|
|
||||||
|
public class TokenSchemeOptions : AuthenticationSchemeOptions
|
||||||
|
{
|
||||||
|
public string Token { get; set; }
|
||||||
|
}
|
||||||
@@ -28,4 +28,8 @@
|
|||||||
</Compile>
|
</Compile>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MoonlightServers.DaemonShared\MoonlightServers.DaemonShared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using Microsoft.Extensions.Logging.Console;
|
using Microsoft.Extensions.Logging.Console;
|
||||||
|
using MoonlightServers.Daemon.Configuration;
|
||||||
using MoonlightServers.Daemon.Helpers;
|
using MoonlightServers.Daemon.Helpers;
|
||||||
|
using MoonlightServers.Daemon.Implementations.TokenScheme;
|
||||||
using MoonlightServers.Daemon.ServerSystem;
|
using MoonlightServers.Daemon.ServerSystem;
|
||||||
using MoonlightServers.Daemon.ServerSystem.Implementations.Docker;
|
using MoonlightServers.Daemon.ServerSystem.Implementations.Docker;
|
||||||
using MoonlightServers.Daemon.ServerSystem.Implementations.Local;
|
using MoonlightServers.Daemon.ServerSystem.Implementations.Local;
|
||||||
using MoonlightServers.Daemon.Services;
|
using MoonlightServers.Daemon.Services;
|
||||||
|
using MoonlightServers.DaemonShared.Http;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -18,7 +21,28 @@ builder.Services.AddSingleton<ServerService>();
|
|||||||
builder.Services.AddDockerServices();
|
builder.Services.AddDockerServices();
|
||||||
builder.Services.AddLocalServices();
|
builder.Services.AddLocalServices();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddHttpClient();
|
||||||
|
builder.Services.AddSingleton<RemoteService>();
|
||||||
|
|
||||||
|
builder.Services.AddOptions<RemoteOptions>().BindConfiguration("Moonlight:Remote");
|
||||||
|
|
||||||
|
var remoteOptions = new RemoteOptions();
|
||||||
|
builder.Configuration.Bind("Moonlight:Remote", remoteOptions);
|
||||||
|
|
||||||
|
builder.Services.AddControllers()
|
||||||
|
.AddApplicationPart(typeof(Program).Assembly)
|
||||||
|
.AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(TokenSchemeHandler.SchemeName)
|
||||||
|
.AddScheme<TokenSchemeOptions, TokenSchemeHandler>(TokenSchemeHandler.SchemeName, options =>
|
||||||
|
{
|
||||||
|
options.Token = remoteOptions!.Token;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Logging.AddFilter("MoonlightServers.Daemon.Implementations.TokenScheme.TokenSchemeHandler", LogLevel.Warning);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -50,8 +74,6 @@ Task.Run(async () =>
|
|||||||
await server.StopAsync();
|
await server.StopAsync();
|
||||||
|
|
||||||
Console.ReadLine();
|
Console.ReadLine();
|
||||||
|
|
||||||
await serverService.DeleteAsync("a0e3ddb4-2c72-4f4c-bc49-35650a4bc5c0");
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
117
MoonlightServers.Daemon/Services/RemoteService.cs
Normal file
117
MoonlightServers.Daemon/Services/RemoteService.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
using MoonlightServers.Daemon.Configuration;
|
||||||
|
using MoonlightServers.DaemonShared.Http;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Daemon.Services;
|
||||||
|
|
||||||
|
public class RemoteService
|
||||||
|
{
|
||||||
|
private readonly IOptions<RemoteOptions> Options;
|
||||||
|
private readonly ILogger<RemoteService> Logger;
|
||||||
|
private readonly IHttpClientFactory ClientFactory;
|
||||||
|
|
||||||
|
public RemoteService(
|
||||||
|
IOptions<RemoteOptions> options,
|
||||||
|
IHttpClientFactory clientFactory,
|
||||||
|
ILogger<RemoteService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Options = options;
|
||||||
|
ClientFactory = clientFactory;
|
||||||
|
Logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CheckReachabilityAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = ClientFactory.CreateClient();
|
||||||
|
|
||||||
|
var request = CreateBaseRequest(HttpMethod.Get, "api/remote/servers/nodes/ping");
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
|
||||||
|
return (int)response.StatusCode;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Logger.LogTrace(e, "An error occured while checking if remote is reachable");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage CreateBaseRequest(
|
||||||
|
[StringSyntax(StringSyntaxAttribute.Uri)]
|
||||||
|
HttpMethod method,
|
||||||
|
string endpoint
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage();
|
||||||
|
|
||||||
|
request.Headers.Add(HeaderNames.Authorization, $"{Options.Value.TokenId} {Options.Value.Token}");
|
||||||
|
request.RequestUri = new Uri(new Uri(Options.Value.EndpointUrl), 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 RemoteException(
|
||||||
|
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 class RemoteException : Exception
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public string Title { get; }
|
||||||
|
public int Status { get; }
|
||||||
|
public string? Detail { get; }
|
||||||
|
public Dictionary<string, string[]>? Errors { get; }
|
||||||
|
|
||||||
|
public RemoteException(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
MoonlightServers.DaemonShared/Http/Daemon/HealthDto.cs
Normal file
6
MoonlightServers.DaemonShared/Http/Daemon/HealthDto.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MoonlightServers.DaemonShared.Http.Daemon;
|
||||||
|
|
||||||
|
public class HealthDto
|
||||||
|
{
|
||||||
|
public int RemoteStatusCode { get; set; }
|
||||||
|
}
|
||||||
14
MoonlightServers.DaemonShared/Http/ProblemDetails.cs
Normal file
14
MoonlightServers.DaemonShared/Http/ProblemDetails.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MoonlightServers.DaemonShared.Http;
|
||||||
|
|
||||||
|
public class ProblemDetails
|
||||||
|
{
|
||||||
|
public string Type { get; set; }
|
||||||
|
|
||||||
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
public int Status { get; set; }
|
||||||
|
|
||||||
|
public string? Detail { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, string[]>? Errors { get; set; }
|
||||||
|
}
|
||||||
14
MoonlightServers.DaemonShared/Http/SerializationContext.cs
Normal file
14
MoonlightServers.DaemonShared/Http/SerializationContext.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MoonlightServers.DaemonShared.Http.Daemon;
|
||||||
|
|
||||||
|
namespace MoonlightServers.DaemonShared.Http;
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(HealthDto))]
|
||||||
|
[JsonSerializable(typeof(ProblemDetails))]
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||||
|
public partial class SerializationContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,4 +6,8 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Http\Panel\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
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}"
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using LucideBlazor;
|
|
||||||
using Moonlight.Frontend.Interfaces;
|
|
||||||
using Moonlight.Frontend.Models;
|
|
||||||
|
|
||||||
namespace MoonlightServers.Frontend.Implementations;
|
|
||||||
|
|
||||||
public class PermissionProvider : IPermissionProvider
|
|
||||||
{
|
|
||||||
public Task<PermissionCategory[]> GetPermissionsAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult<PermissionCategory[]>([
|
|
||||||
new PermissionCategory("Demo", typeof(SparklesIcon), [
|
|
||||||
new Permission("Permissions:Demo", "Demo", "Access to demo page")
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using LucideBlazor;
|
|
||||||
using Moonlight.Frontend.Interfaces;
|
|
||||||
using Moonlight.Frontend.Models;
|
|
||||||
|
|
||||||
namespace MoonlightServers.Frontend.Implementations;
|
|
||||||
|
|
||||||
public sealed class SidebarProvider : ISidebarProvider
|
|
||||||
{
|
|
||||||
public Task<SidebarItem[]> GetItemsAsync()
|
|
||||||
{
|
|
||||||
return Task.FromResult<SidebarItem[]>([
|
|
||||||
new SidebarItem()
|
|
||||||
{
|
|
||||||
Group = "Demo",
|
|
||||||
Name = "Demo",
|
|
||||||
IconType = typeof(SparklesIcon),
|
|
||||||
Path = "/demo"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using LucideBlazor;
|
||||||
|
using Moonlight.Frontend.Interfaces;
|
||||||
|
using Moonlight.Frontend.Models;
|
||||||
|
using MoonlightServers.Shared;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Frontend.Infrastructure;
|
||||||
|
|
||||||
|
public class PermissionProvider : IPermissionProvider
|
||||||
|
{
|
||||||
|
public Task<PermissionCategory[]> GetPermissionsAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult<PermissionCategory[]>([
|
||||||
|
new PermissionCategory("Servers - Nodes", typeof(ServerIcon), [
|
||||||
|
new Permission(Permissions.Nodes.View, "View", "Viewing all nodes"),
|
||||||
|
new Permission(Permissions.Nodes.Create, "Create", "Creating new nodes"),
|
||||||
|
new Permission(Permissions.Nodes.Edit, "Edit", "Editing nodes"),
|
||||||
|
new Permission(Permissions.Nodes.Delete, "Delete", "Deleting nodes"),
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
MoonlightServers.Frontend/Infrastructure/SidebarProvider.cs
Normal file
29
MoonlightServers.Frontend/Infrastructure/SidebarProvider.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using LucideBlazor;
|
||||||
|
using Moonlight.Frontend.Interfaces;
|
||||||
|
using Moonlight.Frontend.Models;
|
||||||
|
using MoonlightServers.Shared;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Frontend.Infrastructure;
|
||||||
|
|
||||||
|
public sealed class SidebarProvider : ISidebarProvider
|
||||||
|
{
|
||||||
|
public Task<SidebarItem[]> GetItemsAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult<SidebarItem[]>([
|
||||||
|
new SidebarItem()
|
||||||
|
{
|
||||||
|
Name = "Servers",
|
||||||
|
IconType = typeof(ServerIcon),
|
||||||
|
Path = "/servers"
|
||||||
|
},
|
||||||
|
new SidebarItem()
|
||||||
|
{
|
||||||
|
Group = "Admin",
|
||||||
|
Name = "Servers",
|
||||||
|
IconType = typeof(ServerIcon),
|
||||||
|
Path = "/admin/servers",
|
||||||
|
Policy = Permissions.Nodes.View
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Folder Include="Client\" />
|
||||||
<Folder Include="wwwroot\"/>
|
<Folder Include="wwwroot\"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Moonlight.Frontend;
|
using Moonlight.Frontend;
|
||||||
using Moonlight.Frontend.Configuration;
|
using Moonlight.Frontend.Configuration;
|
||||||
using Moonlight.Frontend.Interfaces;
|
using Moonlight.Frontend.Interfaces;
|
||||||
using MoonlightServers.Frontend.Implementations;
|
using MoonlightServers.Frontend.Infrastructure;
|
||||||
using SimplePlugin.Abstractions;
|
using SimplePlugin.Abstractions;
|
||||||
|
|
||||||
namespace MoonlightServers.Frontend;
|
namespace MoonlightServers.Frontend;
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
@using Moonlight.Frontend.Helpers
|
|
||||||
@using MoonlightServers.Shared
|
|
||||||
@using MoonlightServers.Shared.Http.Requests
|
|
||||||
@using MoonlightServers.Shared.Http.Responses
|
|
||||||
@using ShadcnBlazor.Buttons
|
|
||||||
@using ShadcnBlazor.Dialogs
|
|
||||||
@using ShadcnBlazor.Extras.AlertDialogs
|
|
||||||
@using ShadcnBlazor.Extras.Forms
|
|
||||||
@using ShadcnBlazor.Fields
|
|
||||||
@using ShadcnBlazor.Inputs
|
|
||||||
|
|
||||||
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
|
|
||||||
|
|
||||||
@inject HttpClient HttpClient
|
|
||||||
@inject AlertDialogService AlertDialogService
|
|
||||||
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Example Form</DialogTitle>
|
|
||||||
<DialogDescription>This forms removes all spaces from the input</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<EnhancedEditForm @ref="Form" OnValidSubmit="OnSubmit" Model="Dto">
|
|
||||||
<DataAnnotationsValidator/>
|
|
||||||
|
|
||||||
<FieldSet>
|
|
||||||
<FormValidationSummary/>
|
|
||||||
|
|
||||||
<FieldGroup>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="formInput">Form Input</FieldLabel>
|
|
||||||
<TextInputField id="formInput" @bind-Value="Dto.TextField"/>
|
|
||||||
<FieldDescription>Input you want to remove the spaces from</FieldDescription>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
</EnhancedEditForm>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button @onclick="() => Form.SubmitAsync()">Submit</Button>
|
|
||||||
<DialogClose/>
|
|
||||||
</DialogFooter>
|
|
||||||
|
|
||||||
@code
|
|
||||||
{
|
|
||||||
private FormSubmitDto Dto = new();
|
|
||||||
private EnhancedEditForm Form;
|
|
||||||
|
|
||||||
private async Task<bool> OnSubmit(EditContext editContext, ValidationMessageStore validationMessageStore)
|
|
||||||
{
|
|
||||||
var response = await HttpClient.PostAsJsonAsync(
|
|
||||||
"api/form",
|
|
||||||
Dto,
|
|
||||||
SerializationContext.Default.Options
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var data = await response.Content.ReadFromJsonAsync<FormResultDto>(
|
|
||||||
SerializationContext.Default.Options
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data == null)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
await AlertDialogService.InfoAsync("Result", data.Result);
|
|
||||||
|
|
||||||
await CloseAsync();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Dto, validationMessageStore);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
@page "/demo"
|
|
||||||
@using LucideBlazor
|
|
||||||
@using MoonlightServers.Frontend.UI.Components
|
|
||||||
@using ShadcnBlazor.Buttons
|
|
||||||
@using ShadcnBlazor.Cards
|
|
||||||
@using ShadcnBlazor.Extras.Dialogs
|
|
||||||
|
|
||||||
@inject DialogService DialogService
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
||||||
<Card ClassName="col-span-1">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Demo</CardTitle>
|
|
||||||
<CardDescription>A cool demo page</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
You successfully used the plugin template to create your moonlight plugin :)
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button>
|
|
||||||
<Slot>
|
|
||||||
<a @attributes="context" href="https://moonlightpa.nl/dev">
|
|
||||||
<ExternalLinkIcon/>
|
|
||||||
Visit documentation
|
|
||||||
</a>
|
|
||||||
</Slot>
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Button @onclick="LaunchFormAsync" Variant="ButtonVariant.Outline">
|
|
||||||
Open Form
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code
|
|
||||||
{
|
|
||||||
private async Task LaunchFormAsync()
|
|
||||||
=> await DialogService.LaunchAsync<FormDialog>();
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
@using System.Net.Http
|
|
||||||
@using System.Net.Http.Json
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
|
||||||
10
MoonlightServers.Frontend/_Imports.razor
Normal file
10
MoonlightServers.Frontend/_Imports.razor
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
14
MoonlightServers.Shared/Admin/Nodes/CreateNodeDto.cs
Normal file
14
MoonlightServers.Shared/Admin/Nodes/CreateNodeDto.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Shared.Admin.Nodes;
|
||||||
|
|
||||||
|
public class CreateNodeDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string HttpEndpointUrl { get; set; }
|
||||||
|
}
|
||||||
9
MoonlightServers.Shared/Admin/Nodes/NodeDto.cs
Normal file
9
MoonlightServers.Shared/Admin/Nodes/NodeDto.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace MoonlightServers.Shared.Admin.Nodes;
|
||||||
|
|
||||||
|
public record NodeDto(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string HttpEndpointUrl,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset UpdatedAt
|
||||||
|
);
|
||||||
8
MoonlightServers.Shared/Admin/Nodes/NodeHealthDto.cs
Normal file
8
MoonlightServers.Shared/Admin/Nodes/NodeHealthDto.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MoonlightServers.Shared.Admin.Nodes;
|
||||||
|
|
||||||
|
public class NodeHealthDto
|
||||||
|
{
|
||||||
|
public int RemoteStatusCode { get; set; }
|
||||||
|
public int StatusCode { get; set; }
|
||||||
|
public bool IsHealthy { get; set; }
|
||||||
|
}
|
||||||
14
MoonlightServers.Shared/Admin/Nodes/UpdateNodeDto.cs
Normal file
14
MoonlightServers.Shared/Admin/Nodes/UpdateNodeDto.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace MoonlightServers.Shared.Admin.Nodes;
|
||||||
|
|
||||||
|
public class UpdateNodeDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string HttpEndpointUrl { get; set; }
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace MoonlightServers.Shared.Http.Requests;
|
|
||||||
|
|
||||||
public class FormSubmitDto
|
|
||||||
{
|
|
||||||
[Required] [MaxLength(32)] public string TextField { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace MoonlightServers.Shared.Http.Responses;
|
|
||||||
|
|
||||||
public class FormResultDto
|
|
||||||
{
|
|
||||||
public string Result { get; set; }
|
|
||||||
}
|
|
||||||
@@ -6,4 +6,12 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Moonlight.Shared" Version="2.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Client\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
15
MoonlightServers.Shared/Permissions.cs
Normal file
15
MoonlightServers.Shared/Permissions.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace MoonlightServers.Shared;
|
||||||
|
|
||||||
|
public static class Permissions
|
||||||
|
{
|
||||||
|
public const string Prefix = "Permissions:Servers.";
|
||||||
|
public static class Nodes
|
||||||
|
{
|
||||||
|
private const string Section = "Nodes";
|
||||||
|
|
||||||
|
public const string View = $"{Prefix}{Section}.{nameof(View)}";
|
||||||
|
public const string Edit = $"{Prefix}{Section}.{nameof(Edit)}";
|
||||||
|
public const string Create = $"{Prefix}{Section}.{nameof(Create)}";
|
||||||
|
public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using MoonlightServers.Shared.Http.Requests;
|
using Moonlight.Shared.Http.Responses;
|
||||||
using MoonlightServers.Shared.Http.Responses;
|
using MoonlightServers.Shared.Admin.Nodes;
|
||||||
|
|
||||||
namespace MoonlightServers.Shared;
|
namespace MoonlightServers.Shared;
|
||||||
|
|
||||||
[JsonSerializable(typeof(FormSubmitDto))]
|
// Admin
|
||||||
[JsonSerializable(typeof(FormResultDto))]
|
|
||||||
|
// - Node
|
||||||
|
[JsonSerializable(typeof(CreateNodeDto))]
|
||||||
|
[JsonSerializable(typeof(UpdateNodeDto))]
|
||||||
|
[JsonSerializable(typeof(NodeDto))]
|
||||||
|
[JsonSerializable(typeof(PagedData<NodeDto>))]
|
||||||
|
|
||||||
|
|
||||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||||
public partial class SerializationContext : JsonSerializerContext
|
public partial class SerializationContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user