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

@@ -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"
}
} }
} }
} }

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;
}
}

View File

@@ -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)
};
}
}

View File

@@ -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);
}

View 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,7 @@
namespace MoonlightServers.Api.Infrastructure.Database.Interfaces;
internal interface IActionTimestamps
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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>

View 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();
}

View File

@@ -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
);
} }
} }

View 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; }
}

View 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
};
}
}

View File

@@ -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)
))
);
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authentication;
namespace MoonlightServers.Daemon.Implementations.TokenScheme;
public class TokenSchemeOptions : AuthenticationSchemeOptions
{
public string Token { get; set; }
}

View File

@@ -28,4 +28,8 @@
</Compile> </Compile>
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MoonlightServers.DaemonShared\MoonlightServers.DaemonShared.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -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)
{ {

View 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;
}
}

View File

@@ -0,0 +1,6 @@
namespace MoonlightServers.DaemonShared.Http.Daemon;
public class HealthDto
{
public int RemoteStatusCode { get; set; }
}

View 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; }
}

View 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
{
}

View File

@@ -6,4 +6,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Folder Include="Http\Panel\" />
</ItemGroup>
</Project> </Project>

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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}"
);
}
);
}
}

View File

@@ -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")
])
]);
}
}

View File

@@ -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"
}
]);
}
}

View File

@@ -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"),
])
]);
}
}

View 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
}
]);
}
}

View File

@@ -16,6 +16,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Client\" />
<Folder Include="wwwroot\"/> <Folder Include="wwwroot\"/>
</ItemGroup> </ItemGroup>

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>();
}

View File

@@ -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

View 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

View 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; }
}

View File

@@ -0,0 +1,9 @@
namespace MoonlightServers.Shared.Admin.Nodes;
public record NodeDto(
int Id,
string Name,
string HttpEndpointUrl,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
);

View 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; }
}

View 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; }
}

View File

@@ -1,8 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Http.Requests;
public class FormSubmitDto
{
[Required] [MaxLength(32)] public string TextField { get; set; }
}

View File

@@ -1,6 +0,0 @@
namespace MoonlightServers.Shared.Http.Responses;
public class FormResultDto
{
public string Result { get; set; }
}

View File

@@ -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>

View 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)}";
}
}

View File

@@ -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
{ {
} }