Implemented template crud, db entities, import/export, ptero and pelican import

This commit is contained in:
2026-03-12 13:00:32 +00:00
parent 7c5dc657dc
commit e7b1e77d0a
68 changed files with 4269 additions and 24 deletions

View File

@@ -8,9 +8,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.3"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@@ -8,8 +8,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.3"/>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all"/> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.3" PrivateAssets="all"/>
<PackageReference Include="SimplePlugin" Version="1.0.2"/> <PackageReference Include="SimplePlugin" Version="1.0.2"/>
<PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2"/> <PackageReference Include="SimplePlugin.Abstractions" Version="1.0.2"/>
</ItemGroup> </ItemGroup>

View File

@@ -7,13 +7,13 @@
@source "../bin/Moonlight.Frontend/*.map"; @source "../bin/Moonlight.Frontend/*.map";
@source "../../../Moonlight.Api/**/*.razor"; @source "../../../MoonlightServers.Api/**/*.razor";
@source "../../../Moonlight.Api/**/*.cs"; @source "../../../MoonlightServers.Api/**/*.cs";
@source "../../../Moonlight.Api/**/*.html"; @source "../../../MoonlightServers.Api/**/*.html";
@source "../../../Moonlight.Frontend/**/*.razor"; @source "../../../MoonlightServers.Frontend/**/*.razor";
@source "../../../Moonlight.Frontend/**/*.cs"; @source "../../../MoonlightServers.Frontend/**/*.cs";
@source "../../../Moonlight.Frontend/**/*.html"; @source "../../../MoonlightServers.Frontend/**/*.html";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

View File

@@ -0,0 +1,173 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
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.Database.Json;
using MoonlightServers.Shared;
using MoonlightServers.Shared.Admin.Templates;
namespace MoonlightServers.Api.Admin.Templates;
[ApiController]
[Route("api/admin/servers/templates")]
public class CrudController : Controller
{
private readonly DatabaseRepository<Template> DatabaseRepository;
private readonly DatabaseRepository<TemplateDockerImage> DockerImageRepository;
public CrudController(
DatabaseRepository<Template> databaseRepository,
DatabaseRepository<TemplateDockerImage> dockerImageRepository
)
{
DatabaseRepository = databaseRepository;
DockerImageRepository = dockerImageRepository;
}
[HttpGet]
[Authorize(Policy = Permissions.Templates.View)]
public async Task<ActionResult<PagedData<TemplateDto>>> 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(Template.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<TemplateDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Templates.View)]
public async Task<ActionResult<DetailedTemplateDto>> GetAsync([FromRoute] int id)
{
var template = await DatabaseRepository
.Query()
.Include(x => x.DefaultDockerImage)
.FirstOrDefaultAsync(x => x.Id == id);
if (template == null)
return Problem("No template with this id found", statusCode: 404);
return TemplateMapper.ToDetailedDto(template);
}
[HttpPost]
[Authorize(Policy = Permissions.Templates.Create)]
public async Task<ActionResult<TemplateDto>> CreateAsync([FromBody] CreateTemplateDto request)
{
var template = TemplateMapper.ToEntity(request);
// Fill in default values
template.LifecycleConfig = new()
{
StartupCommands = [
new StartupCommand
{
DisplayName = "Default Startup",
Command = "bash startup.sh"
}
],
StopCommand = "^C",
OnlineLogPatterns = ["I am online"]
};
template.InstallationConfig = new()
{
DockerImage = "debian",
Script = "#!/bin/bash\necho Installing",
Shell = "/bin/bash"
};
template.FilesConfig = new()
{
ConfigurationFiles = []
};
template.MiscellaneousConfig = new()
{
UseLegacyStartup = true
};
var finalRole = await DatabaseRepository.AddAsync(template);
return TemplateMapper.ToDto(finalRole);
}
[HttpPut("{id:int}")]
[Authorize(Policy = Permissions.Templates.Edit)]
public async Task<ActionResult<DetailedTemplateDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateTemplateDto request)
{
var template = await DatabaseRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (template == null)
return Problem("No template with this id found", statusCode: 404);
TemplateMapper.Merge(template, request);
template.DefaultDockerImage = await DockerImageRepository
.Query()
.Where(x => x.Template.Id == id)
.FirstOrDefaultAsync(x => x.Id == request.DefaultDockerImageId);
await DatabaseRepository.UpdateAsync(template);
return TemplateMapper.ToDetailedDto(template);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Templates.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var template = await DatabaseRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id);
if (template == null)
return Problem("No template with this id found", statusCode: 404);
await DatabaseRepository.RemoveAsync(template);
return NoContent();
}
}

View File

@@ -0,0 +1,144 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Shared.Http.Responses;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Shared;
using MoonlightServers.Shared.Admin.Templates;
namespace MoonlightServers.Api.Admin.Templates;
[ApiController]
[Route("api/admin/servers/templates/{templateId:int}/dockerImages")]
public class DockerImagesController : Controller
{
private readonly DatabaseRepository<TemplateDockerImage> DockerImageRepository;
private readonly DatabaseRepository<Template> TemplateRepository;
public DockerImagesController(
DatabaseRepository<TemplateDockerImage> dockerImageRepository,
DatabaseRepository<Template> templateRepository
)
{
DockerImageRepository = dockerImageRepository;
TemplateRepository = templateRepository;
}
[HttpGet]
[Authorize(Policy = Permissions.Templates.View)]
public async Task<ActionResult<PagedData<DockerImageDto>>> GetAsync(
[FromRoute] int templateId,
[FromQuery] int startIndex,
[FromQuery] int length
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
if (!await TemplateRepository.Query().AnyAsync(x => x.Id == templateId))
return Problem("No template with that id found", statusCode: 404);
// Query building
var query = DockerImageRepository
.Query()
.Where(x => x.Template.Id == templateId);
// Pagination
var data = await query
.OrderBy(x => x.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<DockerImageDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Templates.View)]
public async Task<ActionResult<DockerImageDto>> GetAsync(
[FromRoute] int templateId,
[FromRoute] int id
)
{
var templateDockerImage = await DockerImageRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id && x.Template.Id == templateId);
if (templateDockerImage == null)
return Problem("No template or template dockerImage found with that id");
return TemplateMapper.ToDto(templateDockerImage);
}
[HttpPost]
[Authorize(Policy = Permissions.Templates.Create)]
public async Task<ActionResult<DockerImageDto>> CreateAsync(
[FromRoute] int templateId,
[FromBody] CreateDockerImageDto dto
)
{
var template = await TemplateRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == templateId);
if (template == null)
return Problem("No template with that id found", statusCode: 404);
var dockerImage = TemplateMapper.ToEntity(dto);
dockerImage.Template = template;
var finalDockerImage = await DockerImageRepository.AddAsync(dockerImage);
return TemplateMapper.ToDto(finalDockerImage);
}
[HttpPut("{id:int}")]
[Authorize(Policy = Permissions.Templates.Edit)]
public async Task<ActionResult<DockerImageDto>> UpdateAsync(
[FromRoute] int templateId,
[FromRoute] int id,
[FromBody] UpdateDockerImageDto dto
)
{
var templateDockerImage = await DockerImageRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id && x.Template.Id == templateId);
if (templateDockerImage == null)
return Problem("No template or template dockerImage found with that id");
TemplateMapper.Merge(templateDockerImage, dto);
await DockerImageRepository.UpdateAsync(templateDockerImage);
return TemplateMapper.ToDto(templateDockerImage);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Templates.Delete)]
public async Task<ActionResult> DeleteAsync(
[FromRoute] int templateId,
[FromRoute] int id
)
{
var templateDockerImage = await DockerImageRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id && x.Template.Id == templateId);
if (templateDockerImage == null)
return Problem("No template or template dockerImage found with that id");
await DockerImageRepository.RemoveAsync(templateDockerImage);
return NoContent();
}
}

View File

@@ -0,0 +1,207 @@
using System.Text;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Api.Infrastructure.Database.Json;
using VYaml.Annotations;
using VYaml.Serialization;
namespace MoonlightServers.Api.Admin.Templates;
public class PelicanEggImportService
{
private readonly DatabaseRepository<Template> TemplateRepository;
private readonly DatabaseRepository<TemplateDockerImage> DockerImageRepository;
public PelicanEggImportService(
DatabaseRepository<Template> templateRepository,
DatabaseRepository<TemplateDockerImage> dockerImageRepository
)
{
TemplateRepository = templateRepository;
DockerImageRepository = dockerImageRepository;
}
public async Task<Template> ImportAsync(string content)
{
var egg = YamlSerializer.Deserialize<Egg>(Encoding.UTF8.GetBytes(content));
var template = new Template()
{
AllowUserDockerImageChange = true,
Author = egg.Author,
Description = egg.Description,
DonateUrl = null,
Name = egg.Name,
UpdateUrl = egg.Meta.UpdateUrl,
Version = "1.0.0",
FilesConfig = new FilesConfig()
{
ConfigurationFiles = egg.Config.Files.Select(file => new ConfigurationFile()
{
Path = file.Key,
Parser = file.Value.Parser,
Mappings = file.Value.Find.Select(pair => new ConfigurationFileMapping()
{
Key = pair.Key,
Value = pair.Value
}).ToList()
}).ToList()
},
InstallationConfig = new InstallationConfig()
{
DockerImage = egg.Scripts.Installation.Container,
Script = egg.Scripts.Installation.Script,
Shell = egg.Scripts.Installation.Entrypoint
},
LifecycleConfig = new LifecycleConfig()
{
OnlineLogPatterns = egg.Config.Startup.Values.ToList(),
StopCommand = egg.Config.Stop,
StartupCommands = egg.StartupCommands.Select(x => new StartupCommand()
{
DisplayName = x.Key,
Command = x.Value
}).ToList()
},
MiscellaneousConfig = new MiscellaneousConfig()
{
UseLegacyStartup = true
},
Variables = egg.Variables.Select(variable => new TemplateVariable()
{
Description = variable.Description,
DisplayName = variable.Name,
DefaultValue = variable.DefaultValue,
EnvName = variable.EnvVariable
}).ToList()
};
var finalTemplate = await TemplateRepository.AddAsync(template);
var isFirst = true;
TemplateDockerImage? defaultDockerImage = null;
foreach (var dockerImage in egg.DockerImages)
{
var finalDockerImage = await DockerImageRepository.AddAsync(new TemplateDockerImage()
{
DisplayName = dockerImage.Key,
ImageName = dockerImage.Value,
SkipPulling = false,
Template = finalTemplate
});
if (isFirst)
{
isFirst = false;
defaultDockerImage = finalDockerImage;
}
}
finalTemplate.DefaultDockerImage = defaultDockerImage;
await TemplateRepository.UpdateAsync(finalTemplate);
return finalTemplate;
}
}
[YamlObject]
public partial class Egg
{
[YamlMember("_comment")] public string? Comment { get; set; }
[YamlMember("meta")] public EggMeta Meta { get; set; } = new();
[YamlMember("exported_at")] public string? ExportedAt { get; set; }
[YamlMember("name")] public string Name { get; set; } = string.Empty;
[YamlMember("author")] public string Author { get; set; } = string.Empty;
[YamlMember("uuid")] public string Uuid { get; set; } = string.Empty;
[YamlMember("description")] public string Description { get; set; } = string.Empty;
[YamlMember("image")] public string? Image { get; set; }
[YamlMember("tags")] public List<string> Tags { get; set; } = new();
[YamlMember("features")] public List<string> Features { get; set; } = new();
[YamlMember("docker_images")] public Dictionary<string, string> DockerImages { get; set; } = new();
[YamlMember("file_denylist")] public Dictionary<string, string> FileDenylist { get; set; } = new();
[YamlMember("startup_commands")] public Dictionary<string, string> StartupCommands { get; set; } = new();
[YamlMember("config")] public EggConfig Config { get; set; } = new();
[YamlMember("scripts")] public EggScripts Scripts { get; set; } = new();
[YamlMember("variables")] public List<EggVariable> Variables { get; set; } = new();
}
[YamlObject]
public partial class EggMeta
{
[YamlMember("version")] public string Version { get; set; } = string.Empty;
[YamlMember("update_url")] public string? UpdateUrl { get; set; }
}
[YamlObject]
public partial class EggConfig
{
[YamlMember("files")] public Dictionary<string, EggConfigFile> Files { get; set; } = new();
[YamlMember("startup")] public Dictionary<string, string> Startup { get; set; } = new();
[YamlMember("logs")] public Dictionary<string, string> Logs { get; set; } = new();
[YamlMember("stop")] public string Stop { get; set; } = string.Empty;
}
[YamlObject]
public partial class EggConfigFile
{
[YamlMember("parser")] public string Parser { get; set; } = string.Empty;
[YamlMember("find")] public Dictionary<string, string> Find { get; set; } = new();
}
[YamlObject]
public partial class EggScripts
{
[YamlMember("installation")] public EggInstallationScript Installation { get; set; } = new();
}
[YamlObject]
public partial class EggInstallationScript
{
[YamlMember("script")] public string Script { get; set; } = string.Empty;
[YamlMember("container")] public string Container { get; set; } = string.Empty;
[YamlMember("entrypoint")] public string Entrypoint { get; set; } = string.Empty;
}
[YamlObject]
public partial class EggVariable
{
[YamlMember("name")] public string Name { get; set; } = string.Empty;
[YamlMember("description")] public string Description { get; set; } = string.Empty;
[YamlMember("env_variable")] public string EnvVariable { get; set; } = string.Empty;
[YamlMember("default_value")] public string DefaultValue { get; set; } = string.Empty;
[YamlMember("user_viewable")] public bool UserViewable { get; set; }
[YamlMember("user_editable")] public bool UserEditable { get; set; }
[YamlMember("rules")] public List<string> Rules { get; set; } = new();
[YamlMember("sort")] public int Sort { get; set; }
}

View File

@@ -0,0 +1,255 @@
using System.Text.Json;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Api.Infrastructure.Database.Json;
namespace MoonlightServers.Api.Admin.Templates;
public class PterodactylEggImportService
{
private readonly DatabaseRepository<Template> TemplateRepository;
private readonly DatabaseRepository<TemplateDockerImage> DockerImageRepository;
public PterodactylEggImportService(
DatabaseRepository<Template> templateRepository,
DatabaseRepository<TemplateDockerImage> dockerImageRepository
)
{
TemplateRepository = templateRepository;
DockerImageRepository = dockerImageRepository;
}
public async Task<Template> ImportAsync(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var template = new Template
{
Name = Truncate(root.GetStringOrDefault("name") ?? "Unknown", 30),
Description = Truncate(root.GetStringOrDefault("description") ?? "", 255),
Author = Truncate(root.GetStringOrDefault("author") ?? "", 30),
Version = "1.0.0",
UpdateUrl = root.TryGetProperty("meta", out var meta)
? meta.GetStringOrDefault("update_url")
: null,
DonateUrl = null,
FilesConfig = ParseFilesConfig(root),
LifecycleConfig = ParseLifecycleConfig(root),
InstallationConfig = ParseInstallationConfig(root),
MiscellaneousConfig = new MiscellaneousConfig { UseLegacyStartup = true },
AllowUserDockerImageChange = true,
Variables = ParseVariables(root)
};
var finalTemplate = await TemplateRepository.AddAsync(template);
var dockerImages = ParseDockerImageModels(root);
TemplateDockerImage? defaultDockerImage = null;
var isFirst = true;
foreach (var (displayName, imageName) in dockerImages)
{
var entity = new TemplateDockerImage
{
DisplayName = displayName,
ImageName = imageName,
SkipPulling = false,
Template = finalTemplate
};
var finalEntity = await DockerImageRepository.AddAsync(entity);
if (isFirst)
{
isFirst = false;
defaultDockerImage = finalEntity;
}
}
finalTemplate.DefaultDockerImage = defaultDockerImage;
await TemplateRepository.UpdateAsync(finalTemplate);
return finalTemplate;
}
private static FilesConfig ParseFilesConfig(JsonElement root)
{
var configFiles = new List<ConfigurationFile>();
if (!root.TryGetProperty("config", out var config))
return new FilesConfig { ConfigurationFiles = configFiles };
if (!config.TryGetProperty("files", out var filesElement))
return new FilesConfig { ConfigurationFiles = configFiles };
var filesJson = filesElement.ValueKind == JsonValueKind.String
? filesElement.GetString()
: filesElement.GetRawText();
if (string.IsNullOrWhiteSpace(filesJson) || filesJson == "{}" || filesJson == "[]")
return new FilesConfig { ConfigurationFiles = configFiles };
try
{
using var filesDoc = JsonDocument.Parse(filesJson);
foreach (var fileProperty in filesDoc.RootElement.EnumerateObject())
{
var parser = fileProperty.Value.GetStringOrDefault("parser") ?? "json";
var mappings = new List<ConfigurationFileMapping>();
if (fileProperty.Value.TryGetProperty("find", out var find))
{
foreach (var mapping in find.EnumerateObject())
{
mappings.Add(new ConfigurationFileMapping
{
Key = mapping.Name,
Value = mapping.Value.ValueKind == JsonValueKind.String
? mapping.Value.GetString() ?? ""
: mapping.Value.GetRawText()
});
}
}
configFiles.Add(new ConfigurationFile
{
Path = fileProperty.Name,
Parser = parser,
Mappings = mappings
});
}
}
catch (JsonException)
{
}
return new FilesConfig { ConfigurationFiles = configFiles };
}
private static LifecycleConfig ParseLifecycleConfig(JsonElement root)
{
var stopCommand = "";
var onlinePatterns = new List<string>();
if (root.TryGetProperty("config", out var config))
{
stopCommand = config.GetStringOrDefault("stop") ?? "";
if (config.TryGetProperty("startup", out var startupElement))
{
var startupJson = startupElement.ValueKind == JsonValueKind.String
? startupElement.GetString()
: startupElement.GetRawText();
if (!string.IsNullOrWhiteSpace(startupJson))
{
try
{
using var startupDoc = JsonDocument.Parse(startupJson);
if (startupDoc.RootElement.TryGetProperty("done", out var done))
{
var doneValue = done.ValueKind == JsonValueKind.String
? done.GetString()
: done.GetRawText();
if (!string.IsNullOrWhiteSpace(doneValue))
onlinePatterns.Add(doneValue);
}
}
catch (JsonException)
{
}
}
}
}
return new LifecycleConfig
{
StartupCommands =
[
new StartupCommand
{
DisplayName = "Startup",
Command = root.GetStringOrDefault("startup") ?? ""
}
],
StopCommand = stopCommand,
OnlineLogPatterns = onlinePatterns
};
}
private static InstallationConfig ParseInstallationConfig(JsonElement root)
{
if (!root.TryGetProperty("scripts", out var scripts))
return new InstallationConfig();
if (!scripts.TryGetProperty("installation", out var installation))
return new InstallationConfig();
return new InstallationConfig
{
DockerImage = installation.GetStringOrDefault("container") ?? "",
Shell = installation.GetStringOrDefault("entrypoint") ?? "bash",
Script = installation.GetStringOrDefault("script") ?? ""
};
}
// Returns (DisplayName, ImageName, IsFirst) tuples to avoid a temporary model
private static List<(string DisplayName, string ImageName)> ParseDockerImageModels(JsonElement root)
{
var result = new List<(string, string)>();
if (!root.TryGetProperty("docker_images", out var dockerImages))
return result;
foreach (var img in dockerImages.EnumerateObject())
{
result.Add((
Truncate(img.Name, 30),
Truncate(img.Value.GetString() ?? img.Name, 255)
));
}
return result;
}
private static List<TemplateVariable> ParseVariables(JsonElement root)
{
var variables = new List<TemplateVariable>();
if (!root.TryGetProperty("variables", out var vars))
return variables;
foreach (var v in vars.EnumerateArray())
{
variables.Add(new TemplateVariable
{
DisplayName = Truncate(v.GetStringOrDefault("name") ?? "Variable", 30),
Description = Truncate(v.GetStringOrDefault("description") ?? "", 255),
EnvName = Truncate(v.GetStringOrDefault("env_variable") ?? "", 60),
DefaultValue = Truncate(v.GetStringOrDefault("default_value") ?? "", 1024)
});
}
return variables;
}
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
}
internal static class JsonElementExtensions
{
public static string? GetStringOrDefault(this JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
? prop.GetString()
: null;
}
}

View File

@@ -0,0 +1,28 @@
using System.Diagnostics.CodeAnalysis;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Shared.Admin.Templates;
using Riok.Mapperly.Abstractions;
namespace MoonlightServers.Api.Admin.Templates;
[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 TemplateMapper
{
public static partial TemplateDto ToDto(Template template);
public static partial DetailedTemplateDto ToDetailedDto(Template template);
public static partial IQueryable<TemplateDto> ProjectToDto(this IQueryable<Template> templates);
public static partial Template ToEntity(CreateTemplateDto dto);
public static partial void Merge([MappingTarget] Template template, UpdateTemplateDto dto);
public static partial IQueryable<VariableDto> ProjectToDto(this IQueryable<TemplateVariable> variables);
public static partial VariableDto ToDto(TemplateVariable variable);
public static partial TemplateVariable ToEntity(CreateVariableDto dto);
public static partial void Merge([MappingTarget] TemplateVariable variable, UpdateVariableDto dto);
public static partial IQueryable<DockerImageDto> ProjectToDto(this IQueryable<TemplateDockerImage> dockerImages);
public static partial DockerImageDto ToDto(TemplateDockerImage dockerImage);
public static partial TemplateDockerImage ToEntity(CreateDockerImageDto dto);
public static partial void Merge([MappingTarget] TemplateDockerImage dockerImage, UpdateDockerImageDto dto);
}

View File

@@ -0,0 +1,92 @@
using VYaml.Annotations;
namespace MoonlightServers.Api.Admin.Templates;
[YamlObject]
public partial class TemplateTransferModel
{
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string Author { get; set; } = "";
public string Version { get; set; } = "";
public string? UpdateUrl { get; set; }
public string? DonateUrl { get; set; }
public FilesConfigTransferModel Files { get; set; } = new();
public LifecycleConfigTransferModel Lifecycle { get; set; } = new();
public InstallationConfigTransferModel Installation { get; set; } = new();
public MiscellaneousConfigTransferModel Miscellaneous { get; set; } = new();
public bool AllowUserDockerImageChange { get; set; }
public List<TemplateDockerImageTransferModel> DockerImages { get; set; } = new();
public List<TemplateVariableTransferModel> Variables { get; set; } = new();
}
[YamlObject]
public partial class TemplateDockerImageTransferModel
{
public string DisplayName { get; set; } = "";
public string ImageName { get; set; } = "";
public bool SkipPulling { get; set; }
public bool IsDefault { get; set; }
}
[YamlObject]
public partial class TemplateVariableTransferModel
{
public string DisplayName { get; set; } = "";
public string Description { get; set; } = "";
public string EnvName { get; set; } = "";
public string? DefaultValue { get; set; }
}
[YamlObject]
public partial class FilesConfigTransferModel
{
public List<ConfigurationFileTransferModel> ConfigurationFiles { get; set; } = new();
}
[YamlObject]
public partial class ConfigurationFileTransferModel
{
public string Path { get; set; } = "";
public string Parser { get; set; } = "";
public List<ConfigurationFileMappingTransferModel> Mappings { get; set; } = new();
}
[YamlObject]
public partial class ConfigurationFileMappingTransferModel
{
public string Key { get; set; } = "";
public string? Value { get; set; }
}
[YamlObject]
public partial class LifecycleConfigTransferModel
{
public List<StartupCommandTransferModel> StartupCommands { get; set; } = new();
public string StopCommand { get; set; } = "";
public List<string> OnlineLogPatterns { get; set; } = new();
}
[YamlObject]
public partial class StartupCommandTransferModel
{
public string DisplayName { get; set; } = "";
public string Command { get; set; } = "";
}
[YamlObject]
public partial class InstallationConfigTransferModel
{
public string DockerImage { get; set; } = "";
public string Shell { get; set; } = "";
public string Script { get; set; } = "";
}
[YamlObject]
public partial class MiscellaneousConfigTransferModel
{
public bool UseLegacyStartup { get; set; }
}

View File

@@ -0,0 +1,182 @@
using Microsoft.EntityFrameworkCore;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Api.Infrastructure.Database.Json;
namespace MoonlightServers.Api.Admin.Templates;
public class TemplateTransferService
{
private readonly DatabaseRepository<Template> TemplateRepository;
private readonly DatabaseRepository<TemplateDockerImage> DockerImageRepository;
public TemplateTransferService(DatabaseRepository<Template> templateRepository,
DatabaseRepository<TemplateDockerImage> dockerImageRepository)
{
TemplateRepository = templateRepository;
DockerImageRepository = dockerImageRepository;
}
public async Task<TemplateTransferModel?> ExportAsync(int id)
{
var template = await TemplateRepository
.Query()
.Include(x => x.Variables)
.Include(x => x.DockerImages)
.Include(x => x.DefaultDockerImage)
.FirstOrDefaultAsync(x => x.Id == id);
if (template == null)
return null;
return new()
{
Name = template.Name,
Description = template.Description,
Author = template.Author,
Version = template.Version,
UpdateUrl = template.UpdateUrl,
DonateUrl = template.DonateUrl,
Files = new FilesConfigTransferModel
{
ConfigurationFiles = template.FilesConfig.ConfigurationFiles
.Select(cf => new ConfigurationFileTransferModel
{
Path = cf.Path,
Parser = cf.Parser,
Mappings = cf.Mappings
.Select(m => new ConfigurationFileMappingTransferModel { Key = m.Key, Value = m.Value })
.ToList()
})
.ToList()
},
Lifecycle = new LifecycleConfigTransferModel
{
StartupCommands = template.LifecycleConfig.StartupCommands
.Select(sc => new StartupCommandTransferModel
{ DisplayName = sc.DisplayName, Command = sc.Command })
.ToList(),
StopCommand = template.LifecycleConfig.StopCommand,
OnlineLogPatterns = template.LifecycleConfig.OnlineLogPatterns.ToList()
},
Installation = new InstallationConfigTransferModel
{
DockerImage = template.InstallationConfig.DockerImage,
Shell = template.InstallationConfig.Shell,
Script = template.InstallationConfig.Script
},
Miscellaneous = new MiscellaneousConfigTransferModel
{
UseLegacyStartup = template.MiscellaneousConfig.UseLegacyStartup
},
AllowUserDockerImageChange = template.AllowUserDockerImageChange,
DockerImages = template.DockerImages
.Select(img => new TemplateDockerImageTransferModel
{
DisplayName = img.DisplayName,
ImageName = img.ImageName,
SkipPulling = img.SkipPulling,
IsDefault = template.DefaultDockerImage != null && img.Id == template.DefaultDockerImage.Id
})
.ToList(),
Variables = template.Variables
.Select(v => new TemplateVariableTransferModel
{
DisplayName = v.DisplayName,
Description = v.Description,
EnvName = v.EnvName,
DefaultValue = v.DefaultValue
})
.ToList()
};
}
public async Task<Template> ImportAsync(TemplateTransferModel m)
{
var template = new Template
{
Name = m.Name,
Description = m.Description,
Author = m.Author,
Version = m.Version,
UpdateUrl = m.UpdateUrl,
DonateUrl = m.DonateUrl,
FilesConfig = new FilesConfig
{
ConfigurationFiles = m.Files.ConfigurationFiles
.Select(cf => new ConfigurationFile
{
Path = cf.Path,
Parser = cf.Parser,
Mappings = cf.Mappings
.Select(mp => new ConfigurationFileMapping { Key = mp.Key, Value = mp.Value })
.ToList()
})
.ToList()
},
LifecycleConfig = new LifecycleConfig
{
StartupCommands = m.Lifecycle.StartupCommands
.Select(sc => new StartupCommand { DisplayName = sc.DisplayName, Command = sc.Command })
.ToList(),
StopCommand = m.Lifecycle.StopCommand,
OnlineLogPatterns = m.Lifecycle.OnlineLogPatterns.ToList()
},
InstallationConfig = new InstallationConfig
{
DockerImage = m.Installation.DockerImage,
Shell = m.Installation.Shell,
Script = m.Installation.Script
},
MiscellaneousConfig = new MiscellaneousConfig { UseLegacyStartup = m.Miscellaneous.UseLegacyStartup },
AllowUserDockerImageChange = m.AllowUserDockerImageChange,
Variables = m.Variables
.Select(v => new TemplateVariable
{
DisplayName = v.DisplayName,
Description = v.Description,
EnvName = v.EnvName,
DefaultValue = v.DefaultValue
})
.ToList()
};
var finalTemplate = await TemplateRepository.AddAsync(template);
TemplateDockerImage? defaultDockerImage = null;
foreach (var img in m.DockerImages)
{
var entity = new TemplateDockerImage
{
DisplayName = img.DisplayName,
ImageName = img.ImageName,
SkipPulling = img.SkipPulling,
Template = template
};
var finalEntity = await DockerImageRepository.AddAsync(entity);
if (img.IsDefault)
defaultDockerImage = finalEntity;
}
finalTemplate.DefaultDockerImage = defaultDockerImage;
await TemplateRepository.UpdateAsync(finalTemplate);
return finalTemplate;
}
}

View File

@@ -0,0 +1,88 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using MoonlightServers.Shared.Admin.Templates;
using VYaml.Serialization;
namespace MoonlightServers.Api.Admin.Templates;
[ApiController]
[Route("api/admin/servers/templates")]
public class TransferController : Controller
{
private readonly TemplateTransferService TransferService;
public TransferController(TemplateTransferService transferService)
{
TransferService = transferService;
}
[HttpGet("{id:int}/export")]
public async Task<ActionResult> ExportAsync([FromRoute] int id)
{
var transferModel = await TransferService.ExportAsync(id);
if (transferModel == null)
return Problem("No template with that id found", statusCode: 404);
var yml = YamlSerializer.Serialize(transferModel, new YamlSerializerOptions
{
Resolver = CompositeResolver.Create([
GeneratedResolver.Instance,
StandardResolver.Instance
])
});
return File(yml.ToArray(), "text/yaml", $"{transferModel.Name}.yml");
}
[HttpPost("import")]
public async Task<ActionResult<TemplateDto>> ImportAsync()
{
string content;
await using (Stream receiveStream = Request.Body)
using (StreamReader readStream = new StreamReader(receiveStream))
content = await readStream.ReadToEndAsync();
if(content.Contains("version: PLCN_v3"))
{
var importService = HttpContext.RequestServices.GetRequiredService<PelicanEggImportService>();
var template = await importService.ImportAsync(content);
return TemplateMapper.ToDto(template);
}
if (
content.Contains("PTDL_v2", StringComparison.OrdinalIgnoreCase) ||
content.Contains("PLCN_v1", StringComparison.OrdinalIgnoreCase) ||
content.Contains("PLCN_v2", StringComparison.OrdinalIgnoreCase) ||
content.Contains("PLCN_v3", StringComparison.OrdinalIgnoreCase)
)
{
var importService = HttpContext.RequestServices.GetRequiredService<PterodactylEggImportService>();
var template = await importService.ImportAsync(content);
return TemplateMapper.ToDto(template);
}
else
{
var transferModel = YamlSerializer.Deserialize<TemplateTransferModel>(
Encoding.UTF8.GetBytes(content),
new YamlSerializerOptions
{
Resolver = CompositeResolver.Create([
GeneratedResolver.Instance,
StandardResolver.Instance
])
}
);
var template = await TransferService.ImportAsync(transferModel);
return TemplateMapper.ToDto(template);
}
}
}

View File

@@ -0,0 +1,144 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Shared.Http.Responses;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Database.Entities;
using MoonlightServers.Shared;
using MoonlightServers.Shared.Admin.Templates;
namespace MoonlightServers.Api.Admin.Templates;
[ApiController]
[Route("api/admin/servers/templates/{templateId:int}/variables")]
public class VariablesController : Controller
{
private readonly DatabaseRepository<TemplateVariable> VariableRepository;
private readonly DatabaseRepository<Template> TemplateRepository;
public VariablesController(
DatabaseRepository<TemplateVariable> variableRepository,
DatabaseRepository<Template> templateRepository
)
{
VariableRepository = variableRepository;
TemplateRepository = templateRepository;
}
[HttpGet]
[Authorize(Policy = Permissions.Templates.View)]
public async Task<ActionResult<PagedData<VariableDto>>> GetAsync(
[FromRoute] int templateId,
[FromQuery] int startIndex,
[FromQuery] int length
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
if (!await TemplateRepository.Query().AnyAsync(x => x.Id == templateId))
return Problem("No template with that id found", statusCode: 404);
// Query building
var query = VariableRepository
.Query()
.Where(x => x.Template.Id == templateId);
// Pagination
var data = await query
.OrderBy(x => x.Id)
.ProjectToDto()
.Skip(startIndex)
.Take(length)
.ToArrayAsync();
var total = await query.CountAsync();
return new PagedData<VariableDto>(data, total);
}
[HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Templates.View)]
public async Task<ActionResult<VariableDto>> GetAsync(
[FromRoute] int templateId,
[FromRoute] int id
)
{
var templateVariable = await VariableRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id && x.Template.Id == templateId);
if (templateVariable == null)
return Problem("No template or template variable found with that id");
return TemplateMapper.ToDto(templateVariable);
}
[HttpPost]
[Authorize(Policy = Permissions.Templates.Create)]
public async Task<ActionResult<VariableDto>> CreateAsync(
[FromRoute] int templateId,
[FromBody] CreateVariableDto dto
)
{
var template = await TemplateRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == templateId);
if (template == null)
return Problem("No template with that id found", statusCode: 404);
var variable = TemplateMapper.ToEntity(dto);
variable.Template = template;
var finalVariable = await VariableRepository.AddAsync(variable);
return TemplateMapper.ToDto(finalVariable);
}
[HttpPut("{id:int}")]
[Authorize(Policy = Permissions.Templates.Edit)]
public async Task<ActionResult<VariableDto>> UpdateAsync(
[FromRoute] int templateId,
[FromRoute] int id,
[FromBody] UpdateVariableDto dto
)
{
var templateVariable = await VariableRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id && x.Template.Id == templateId);
if (templateVariable == null)
return Problem("No template or template variable found with that id");
TemplateMapper.Merge(templateVariable, dto);
await VariableRepository.UpdateAsync(templateVariable);
return TemplateMapper.ToDto(templateVariable);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Templates.Delete)]
public async Task<ActionResult> DeleteAsync(
[FromRoute] int templateId,
[FromRoute] int id
)
{
var templateVariable = await VariableRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == id && x.Template.Id == templateId);
if (templateVariable == null)
return Problem("No template or template variable found with that id");
await VariableRepository.RemoveAsync(templateVariable);
return NoContent();
}
}

View File

@@ -8,6 +8,9 @@ namespace MoonlightServers.Api.Infrastructure.Database;
public class DataContext : DbContext public class DataContext : DbContext
{ {
public DbSet<Node> Nodes { get; set; } public DbSet<Node> Nodes { get; set; }
public DbSet<Template> Templates { get; set; }
public DbSet<TemplateDockerImage> TemplateDockerImages { get; set; }
public DbSet<TemplateVariable> TemplateVariablesVariables { get; set; }
private readonly IOptions<DatabaseOptions> Options; private readonly IOptions<DatabaseOptions> Options;
public DataContext(IOptions<DatabaseOptions> options) public DataContext(IOptions<DatabaseOptions> options)
@@ -34,5 +37,25 @@ public class DataContext : DbContext
modelBuilder.HasDefaultSchema("servers"); modelBuilder.HasDefaultSchema("servers");
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Template>()
.ComplexProperty(x => x.FilesConfig, builder => builder.ToJson())
.ComplexProperty(x => x.LifecycleConfig, builder => builder.ToJson())
.ComplexProperty(x => x.InstallationConfig, builder => builder.ToJson())
.ComplexProperty(x => x.MiscellaneousConfig, builder => builder.ToJson());
// One-to-many: Template => DockerImages
modelBuilder.Entity<Template>()
.HasMany(t => t.DockerImages)
.WithOne(d => d.Template)
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade);
// One-to-one: Template => DefaultDockerImage
modelBuilder.Entity<Template>()
.HasOne(t => t.DefaultDockerImage)
.WithOne()
.HasForeignKey<Template>("DefaultDockerImageId")
.OnDelete(DeleteBehavior.SetNull);
} }
} }

View File

@@ -28,7 +28,7 @@ public class DbMigrationService : IHostedLifecycleService
if (migrationNames.Length == 0) if (migrationNames.Length == 0)
{ {
Logger.LogDebug("No pending migrations found"); Logger.LogTrace("No pending migrations found");
return; return;
} }

View File

@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using MoonlightServers.Api.Infrastructure.Database.Json;
namespace MoonlightServers.Api.Infrastructure.Database.Entities;
public class Template
{
public int Id { get; set; }
// Meta
[MaxLength(30)]
public string Name { get; set; }
[MaxLength(255)]
public string Description { get; set; }
[MaxLength(30)]
public string Author { get; set; }
[MaxLength(30)]
public string Version { get; set; }
[MaxLength(2048)]
public string? UpdateUrl { get; set; }
[MaxLength(2048)]
public string? DonateUrl { get; set; }
// JSON Options
public FilesConfig FilesConfig { get; set; }
public LifecycleConfig LifecycleConfig { get; set; }
public InstallationConfig InstallationConfig { get; set; }
public MiscellaneousConfig MiscellaneousConfig { get; set; }
// Docker Images
public bool AllowUserDockerImageChange { get; set; }
public TemplateDockerImage? DefaultDockerImage { get; set; }
public List<TemplateDockerImage> DockerImages { get; set; } = new();
// Variables
public List<TemplateVariable> Variables { get; set; } = new();
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Api.Infrastructure.Database.Entities;
public class TemplateDockerImage
{
public int Id { get; set; }
[MaxLength(30)]
public string DisplayName { get; set; }
[MaxLength(255)]
public string ImageName { get; set; }
public bool SkipPulling { get; set; }
// Relations
public Template Template { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Api.Infrastructure.Database.Entities;
public class TemplateVariable
{
public int Id { get; set; }
[MaxLength(30)]
public string DisplayName { get; set; }
[MaxLength(255)]
public string Description { get; set; }
[MaxLength(60)]
public string EnvName { get; set; }
[MaxLength(1024)]
public string? DefaultValue { get; set; }
// Relations
public Template Template { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace MoonlightServers.Api.Infrastructure.Database.Json;
public class FilesConfig
{
public List<ConfigurationFile> ConfigurationFiles { get; set; } = [];
}
public class ConfigurationFile
{
public string Path { get; set; } = string.Empty;
public string Parser { get; set; } = string.Empty;
public List<ConfigurationFileMapping> Mappings { get; set; } = [];
}
public class ConfigurationFileMapping
{
public string Key { get; set; } = string.Empty;
public string? Value { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace MoonlightServers.Api.Infrastructure.Database.Json;
public class InstallationConfig
{
public string DockerImage { get; set; } = string.Empty;
public string Shell { get; set; } = string.Empty;
public string Script { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
namespace MoonlightServers.Api.Infrastructure.Database.Json;
public class LifecycleConfig
{
public List<StartupCommand> StartupCommands { get; set; } = [];
public string StopCommand { get; set; } = string.Empty;
public List<string> OnlineLogPatterns { get; set; } = [];
}
public class StartupCommand
{
public string DisplayName { get; set; } = string.Empty;
public string Command { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,6 @@
namespace MoonlightServers.Api.Infrastructure.Database.Json;
public class MiscellaneousConfig
{
public bool UseLegacyStartup { get; set; }
}

View File

@@ -0,0 +1,315 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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("20260312075719_AddedTemplateEntities")]
partial class AddedTemplateEntities
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("servers")
.HasAnnotation("ProductVersion", "10.0.3")
.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");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Template", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowUserDockerImageChange")
.HasColumnType("boolean");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int?>("DefaultDockerImageId")
.HasColumnType("integer");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("DonateUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("UpdateUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.ComplexProperty(typeof(Dictionary<string, object>), "FilesConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.FilesConfig#FilesConfig", b1 =>
{
b1.IsRequired();
b1.ComplexCollection(typeof(List<Dictionary<string, object>>), "ConfigurationFiles", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.FilesConfig#FilesConfig.ConfigurationFiles#ConfigurationFile", b2 =>
{
b2.IsRequired();
b2.Property<string>("Parser")
.IsRequired();
b2.Property<string>("Path")
.IsRequired();
b2.ComplexCollection(typeof(List<Dictionary<string, object>>), "Mappings", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.FilesConfig#FilesConfig.ConfigurationFiles#ConfigurationFile.Mappings#ConfigurationFileMapping", b3 =>
{
b3.IsRequired();
b3.Property<string>("Key")
.IsRequired();
b3.Property<string>("Value")
.IsRequired();
});
});
b1
.ToJson("FilesConfig")
.HasColumnType("jsonb");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "InstallationConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.InstallationConfig#InstallationConfig", b1 =>
{
b1.IsRequired();
b1.Property<string>("DockerImage")
.IsRequired();
b1.Property<string>("Script")
.IsRequired();
b1.Property<string>("Shell")
.IsRequired();
b1
.ToJson("InstallationConfig")
.HasColumnType("jsonb");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "LifecycleConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.LifecycleConfig#LifecycleConfig", b1 =>
{
b1.IsRequired();
b1.PrimitiveCollection<string>("OnlineLogPatterns")
.IsRequired();
b1.Property<string>("StopCommand")
.IsRequired();
b1.ComplexCollection(typeof(List<Dictionary<string, object>>), "StartupCommands", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.LifecycleConfig#LifecycleConfig.StartupCommands#StartupCommand", b2 =>
{
b2.IsRequired();
b2.Property<string>("Command")
.IsRequired();
b2.Property<string>("DisplayName")
.IsRequired();
});
b1
.ToJson("LifecycleConfig")
.HasColumnType("jsonb");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "MiscellaneousConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.MiscellaneousConfig#MiscellaneousConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("UseLegacyStartup");
b1
.ToJson("MiscellaneousConfig")
.HasColumnType("jsonb");
});
b.HasKey("Id");
b.HasIndex("DefaultDockerImageId")
.IsUnique();
b.ToTable("Templates", "servers");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateDockerImage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("SkipPulling")
.HasColumnType("boolean");
b.Property<int>("TemplateId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TemplateId");
b.ToTable("TemplateDockerImages", "servers");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateVariable", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DefaultValue")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("EnvName")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<int>("TemplateId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TemplateId");
b.ToTable("TemplateVariablesVariables", "servers");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Template", b =>
{
b.HasOne("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateDockerImage", "DefaultDockerImage")
.WithOne()
.HasForeignKey("MoonlightServers.Api.Infrastructure.Database.Entities.Template", "DefaultDockerImageId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("DefaultDockerImage");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateDockerImage", b =>
{
b.HasOne("MoonlightServers.Api.Infrastructure.Database.Entities.Template", "Template")
.WithMany("DockerImages")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Template");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateVariable", b =>
{
b.HasOne("MoonlightServers.Api.Infrastructure.Database.Entities.Template", "Template")
.WithMany("Variables")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Template");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Template", b =>
{
b.Navigation("DockerImages");
b.Navigation("Variables");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MoonlightServers.Api.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class AddedTemplateEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TemplateDockerImages",
schema: "servers",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DisplayName = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
ImageName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
SkipPulling = table.Column<bool>(type: "boolean", nullable: false),
TemplateId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TemplateDockerImages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Templates",
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(30)", maxLength: 30, nullable: false),
Description = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Author = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
Version = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
UpdateUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
DonateUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
AllowUserDockerImageChange = table.Column<bool>(type: "boolean", nullable: false),
DefaultDockerImageId = table.Column<int>(type: "integer", nullable: true),
FilesConfig = table.Column<string>(type: "jsonb", nullable: false),
InstallationConfig = table.Column<string>(type: "jsonb", nullable: false),
LifecycleConfig = table.Column<string>(type: "jsonb", nullable: false),
MiscellaneousConfig = table.Column<string>(type: "jsonb", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Templates", x => x.Id);
table.ForeignKey(
name: "FK_Templates_TemplateDockerImages_DefaultDockerImageId",
column: x => x.DefaultDockerImageId,
principalSchema: "servers",
principalTable: "TemplateDockerImages",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "TemplateVariablesVariables",
schema: "servers",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DisplayName = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
Description = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
EnvName = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
DefaultValue = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
TemplateId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TemplateVariablesVariables", x => x.Id);
table.ForeignKey(
name: "FK_TemplateVariablesVariables_Templates_TemplateId",
column: x => x.TemplateId,
principalSchema: "servers",
principalTable: "Templates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_TemplateDockerImages_TemplateId",
schema: "servers",
table: "TemplateDockerImages",
column: "TemplateId");
migrationBuilder.CreateIndex(
name: "IX_Templates_DefaultDockerImageId",
schema: "servers",
table: "Templates",
column: "DefaultDockerImageId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TemplateVariablesVariables_TemplateId",
schema: "servers",
table: "TemplateVariablesVariables",
column: "TemplateId");
migrationBuilder.AddForeignKey(
name: "FK_TemplateDockerImages_Templates_TemplateId",
schema: "servers",
table: "TemplateDockerImages",
column: "TemplateId",
principalSchema: "servers",
principalTable: "Templates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TemplateDockerImages_Templates_TemplateId",
schema: "servers",
table: "TemplateDockerImages");
migrationBuilder.DropTable(
name: "TemplateVariablesVariables",
schema: "servers");
migrationBuilder.DropTable(
name: "Templates",
schema: "servers");
migrationBuilder.DropTable(
name: "TemplateDockerImages",
schema: "servers");
}
}
}

View File

@@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -18,7 +19,7 @@ namespace MoonlightServers.Api.Infrastructure.Database.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasDefaultSchema("servers") .HasDefaultSchema("servers")
.HasAnnotation("ProductVersion", "10.0.1") .HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -61,6 +62,250 @@ namespace MoonlightServers.Api.Infrastructure.Database.Migrations
b.ToTable("Nodes", "servers"); b.ToTable("Nodes", "servers");
}); });
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Template", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowUserDockerImageChange")
.HasColumnType("boolean");
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int?>("DefaultDockerImageId")
.HasColumnType("integer");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("DonateUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("UpdateUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.ComplexProperty(typeof(Dictionary<string, object>), "FilesConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.FilesConfig#FilesConfig", b1 =>
{
b1.IsRequired();
b1.ComplexCollection(typeof(List<Dictionary<string, object>>), "ConfigurationFiles", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.FilesConfig#FilesConfig.ConfigurationFiles#ConfigurationFile", b2 =>
{
b2.IsRequired();
b2.Property<string>("Parser")
.IsRequired();
b2.Property<string>("Path")
.IsRequired();
b2.ComplexCollection(typeof(List<Dictionary<string, object>>), "Mappings", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.FilesConfig#FilesConfig.ConfigurationFiles#ConfigurationFile.Mappings#ConfigurationFileMapping", b3 =>
{
b3.IsRequired();
b3.Property<string>("Key")
.IsRequired();
b3.Property<string>("Value")
.IsRequired();
});
});
b1
.ToJson("FilesConfig")
.HasColumnType("jsonb");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "InstallationConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.InstallationConfig#InstallationConfig", b1 =>
{
b1.IsRequired();
b1.Property<string>("DockerImage")
.IsRequired();
b1.Property<string>("Script")
.IsRequired();
b1.Property<string>("Shell")
.IsRequired();
b1
.ToJson("InstallationConfig")
.HasColumnType("jsonb");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "LifecycleConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.LifecycleConfig#LifecycleConfig", b1 =>
{
b1.IsRequired();
b1.PrimitiveCollection<string>("OnlineLogPatterns")
.IsRequired();
b1.Property<string>("StopCommand")
.IsRequired();
b1.ComplexCollection(typeof(List<Dictionary<string, object>>), "StartupCommands", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.LifecycleConfig#LifecycleConfig.StartupCommands#StartupCommand", b2 =>
{
b2.IsRequired();
b2.Property<string>("Command")
.IsRequired();
b2.Property<string>("DisplayName")
.IsRequired();
});
b1
.ToJson("LifecycleConfig")
.HasColumnType("jsonb");
});
b.ComplexProperty(typeof(Dictionary<string, object>), "MiscellaneousConfig", "MoonlightServers.Api.Infrastructure.Database.Entities.Template.MiscellaneousConfig#MiscellaneousConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("UseLegacyStartup");
b1
.ToJson("MiscellaneousConfig")
.HasColumnType("jsonb");
});
b.HasKey("Id");
b.HasIndex("DefaultDockerImageId")
.IsUnique();
b.ToTable("Templates", "servers");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateDockerImage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("ImageName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("SkipPulling")
.HasColumnType("boolean");
b.Property<int>("TemplateId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TemplateId");
b.ToTable("TemplateDockerImages", "servers");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateVariable", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DefaultValue")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("EnvName")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<int>("TemplateId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TemplateId");
b.ToTable("TemplateVariablesVariables", "servers");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Template", b =>
{
b.HasOne("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateDockerImage", "DefaultDockerImage")
.WithOne()
.HasForeignKey("MoonlightServers.Api.Infrastructure.Database.Entities.Template", "DefaultDockerImageId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("DefaultDockerImage");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateDockerImage", b =>
{
b.HasOne("MoonlightServers.Api.Infrastructure.Database.Entities.Template", "Template")
.WithMany("DockerImages")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Template");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.TemplateVariable", b =>
{
b.HasOne("MoonlightServers.Api.Infrastructure.Database.Entities.Template", "Template")
.WithMany("Variables")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Template");
});
modelBuilder.Entity("MoonlightServers.Api.Infrastructure.Database.Entities.Template", b =>
{
b.Navigation("DockerImages");
b.Navigation("Variables");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

@@ -12,6 +12,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" />
<PackageReference Include="Moonlight.Api" Version="2.1.0"> <PackageReference Include="Moonlight.Api" Version="2.1.0">
<ExcludeAssets>content;contentfiles</ExcludeAssets> <ExcludeAssets>content;contentfiles</ExcludeAssets>
</PackageReference> </PackageReference>

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Moonlight.Api; using Moonlight.Api;
using MoonlightServers.Api.Admin.Nodes; using MoonlightServers.Api.Admin.Nodes;
using MoonlightServers.Api.Admin.Templates;
using MoonlightServers.Api.Infrastructure.Configuration; using MoonlightServers.Api.Infrastructure.Configuration;
using MoonlightServers.Api.Infrastructure.Database; using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Implementations.NodeToken; using MoonlightServers.Api.Infrastructure.Implementations.NodeToken;
@@ -29,6 +30,10 @@ public class Startup : MoonlightPlugin
builder.Services.AddDbContext<DataContext>(); builder.Services.AddDbContext<DataContext>();
builder.Services.AddHostedService<DbMigrationService>(); builder.Services.AddHostedService<DbMigrationService>();
builder.Services.AddScoped<TemplateTransferService>();
builder.Services.AddScoped<PterodactylEggImportService>();
builder.Services.AddScoped<PelicanEggImportService>();
builder.Services.AddSingleton<NodeService>(); builder.Services.AddSingleton<NodeService>();
var nodeTokenOptions = new NodeTokenOptions(); var nodeTokenOptions = new NodeTokenOptions();

View File

@@ -0,0 +1,75 @@
@page "/admin/servers"
@using LucideBlazor
@using MoonlightServers.Shared
@using ShadcnBlazor.Tab
@inject IAuthorizationService AuthorizationService
@inject NavigationManager Navigation
@attribute [Authorize(Policy = Permissions.Servers.View)]
<Tabs DefaultValue="@(Tab ?? "servers")" OnValueChanged="OnTabChanged">
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger Value="servers" Disabled="@(!ServersAccess.Succeeded)">
<ContainerIcon />
Servers
</TabsTrigger>
<TabsTrigger Value="nodes" Disabled="@(!NodesAccess.Succeeded)">
<ServerIcon />
Nodes
</TabsTrigger>
<TabsTrigger Value="templates" Disabled="@(!TemplatesAccess.Succeeded)">
<Package2Icon />
Templates
</TabsTrigger>
<TabsTrigger Value="manager" Disabled="@(!NodesAccess.Succeeded)">
<TableIcon />
Manager
</TabsTrigger>
</TabsList>
@if (ServersAccess.Succeeded)
{
<TabsContent Value="servers">
</TabsContent>
}
@if (NodesAccess.Succeeded)
{
<TabsContent Value="nodes">
<MoonlightServers.Frontend.Admin.Nodes.Overview />
</TabsContent>
}
@if (TemplatesAccess.Succeeded)
{
<TabsContent Value="templates">
<MoonlightServers.Frontend.Admin.Templates.Overview />
</TabsContent>
}
</Tabs>
@code
{
[Parameter]
[SupplyParameterFromQuery(Name = "tab")]
public string? Tab { get; set; }
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private AuthorizationResult ServersAccess;
private AuthorizationResult NodesAccess;
private AuthorizationResult TemplatesAccess;
protected override async Task OnInitializedAsync()
{
var authState = await AuthState;
ServersAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Servers.View);
NodesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Nodes.View);
TemplatesAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Templates.View);
}
private void OnTabChanged(string tab) => Navigation.NavigateTo($"/admin/servers?tab={tab}");
}

View File

@@ -27,7 +27,7 @@
<div class="flex flex-row gap-x-1.5"> <div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary"> <Button Variant="ButtonVariant.Secondary">
<Slot> <Slot>
<a href="/admin/servers/nodes" @attributes="context"> <a href="/admin/servers?tab=nodes" @attributes="context">
<ChevronLeftIcon/> <ChevronLeftIcon/>
Back Back
</a> </a>
@@ -50,7 +50,7 @@
<FieldSet> <FieldSet>
<FieldGroup> <FieldGroup>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5"> <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<Field> <Field>
<FieldLabel for="nodeName">Name</FieldLabel> <FieldLabel for="nodeName">Name</FieldLabel>
<TextInputField <TextInputField
@@ -98,7 +98,7 @@
$"Successfully created node {Request.Name}" $"Successfully created node {Request.Name}"
); );
Navigation.NavigateTo("/admin/servers/nodes"); Navigation.NavigateTo("/admin/servers?tab=nodes");
return true; return true;
} }
} }

View File

@@ -47,7 +47,7 @@
<div class="flex flex-row gap-x-1.5"> <div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary"> <Button Variant="ButtonVariant.Secondary">
<Slot> <Slot>
<a href="/admin/servers/nodes" @attributes="context"> <a href="/admin/servers?tab=nodes" @attributes="context">
<ChevronLeftIcon/> <ChevronLeftIcon/>
Back Back
</a> </a>
@@ -70,7 +70,7 @@
<FieldSet> <FieldSet>
<FieldGroup> <FieldGroup>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5"> <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<Field> <Field>
<FieldLabel for="nodeName">Name</FieldLabel> <FieldLabel for="nodeName">Name</FieldLabel>
<TextInputField <TextInputField
@@ -148,7 +148,7 @@
$"Successfully updated node {Request.Name}" $"Successfully updated node {Request.Name}"
); );
Navigation.NavigateTo("/admin/servers/nodes"); Navigation.NavigateTo("/admin/servers?tab=nodes");
return true; return true;
} }
} }

View File

@@ -1,5 +1,3 @@
@page "/admin/servers/nodes"
@using LucideBlazor @using LucideBlazor
@using Moonlight.Shared.Http.Requests @using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses @using Moonlight.Shared.Http.Responses
@@ -15,7 +13,6 @@
@inject HttpClient HttpClient @inject HttpClient HttpClient
@inject AlertDialogService AlertDialogService @inject AlertDialogService AlertDialogService
@inject DialogService DialogService
@inject ToastService ToastService @inject ToastService ToastService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IAuthorizationService AuthorizationService @inject IAuthorizationService AuthorizationService
@@ -61,7 +58,6 @@
</CellTemplate> </CellTemplate>
</TemplateColumn> </TemplateColumn>
<PropertyColumn Title="HTTP Endpoint" <PropertyColumn Title="HTTP Endpoint"
Identifier="@nameof(NodeDto.HttpEndpointUrl)"
Field="u => u.HttpEndpointUrl"/> Field="u => u.HttpEndpointUrl"/>
<TemplateColumn> <TemplateColumn>
<CellTemplate> <CellTemplate>

View File

@@ -0,0 +1,78 @@
@using LucideBlazor
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Tabels
@inherits Editor<List<MoonlightServers.Shared.Admin.Templates.UpdateConfigurationFileMappingDto>>
<div class="rounded-md bg-card shadow-sm border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
Key
</TableHead>
<TableHead>
Value
</TableHead>
<TableHead ClassName="w-10"/>
</TableRow>
</TableHeader>
<TableBody>
@foreach (var mapping in Value)
{
<TableRow @key="mapping">
<TableCell>
<TextInputField @bind-Value="mapping.Key"
placeholder="server-port"/>
</TableCell>
<TableCell>
<TextInputField @bind-Value="mapping.Value"
placeholder="{{SERVER_PORT}}"/>
</TableCell>
<TableCell ClassName="text-right pr-4">
<Button @onclick="() => DeleteAsync(mapping)"
Size="ButtonSize.Icon"
Variant="ButtonVariant.Destructive">
<Trash2Icon/>
</Button>
</TableCell>
</TableRow>
}
<TableRow>
<TableCell colspan="999999">
<div class="flex justify-end">
<Button
@onclick="AddAsync"
Variant="ButtonVariant.Outline"
Size="ButtonSize.Sm">
<PlusIcon/>
Add Mapping
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
@code
{
private async Task DeleteAsync(UpdateConfigurationFileMappingDto mappingDto)
{
Value.Remove(mappingDto);
await ValueChanged.InvokeAsync(Value);
}
private async Task AddAsync()
{
Value.Add(new UpdateConfigurationFileMappingDto()
{
Key = "Change me",
Value = "{{CHANGE_ME}}"
});
await ValueChanged.InvokeAsync(Value);
}
}

View File

@@ -0,0 +1,76 @@
@using LucideBlazor
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Accordions
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
<div class="mb-5 flex justify-end">
<Button @onclick="Add">
<PlusIcon/>
Create
</Button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
@foreach (var file in FilesConfig.ConfigurationFiles)
{
<Accordion ClassName="-space-y-px" Type="AccordionType.Single">
<AccordionItem
ClassName="overflow-hidden border bg-card px-4 first:rounded-t-lg last:rounded-b-lg last:border-b"
Value="element">
<AccordionTrigger ClassName="hover:no-underline">
<div class="flex items-center gap-3">
<FileCogIcon/>
<span class="text-left">@file.Path</span>
</div>
</AccordionTrigger>
<AccordionContent ClassName="ps-7">
<FieldGroup>
<FieldSet>
<Field>
@{
var id = $"configFilePath{file.GetHashCode()}";
}
<FieldLabel for="@id">Path</FieldLabel>
<TextInputField
@bind-Value="file.Path"
id="@id"/>
</Field>
<Field>
@{
var id = $"configFileParser{file.GetHashCode()}";
}
<FieldLabel for="@id">Parser</FieldLabel>
<TextInputField
@bind-Value="file.Parser"
id="@id"/>
</Field>
</FieldSet>
<FieldSet>
<ConfigFileMappingEditor @bind-Value="file.Mappings" />
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<Button Variant="ButtonVariant.Destructive"
@onclick="() => Delete(file)">
Delete
</Button>
</Field>
</FieldGroup>
</AccordionContent>
</AccordionItem>
</Accordion>
}
</div>
@code
{
[Parameter] public UpdateFilesConfigDto FilesConfig { get; set; }
private void Add()
=> FilesConfig.ConfigurationFiles.Add(new());
private void Delete(UpdateConfigurationFileDto dto)
=> FilesConfig.ConfigurationFiles.Remove(dto);
}

View File

@@ -0,0 +1,132 @@
@page "/admin/servers/templates/create"
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using MoonlightServers.Shared
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Tab
@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 Template</h1>
<div class="text-muted-foreground">
Create a new template
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/servers?tab=templates" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<SubmitButton>
<CheckIcon/>
Continue
</SubmitButton>
</div>
</div>
<DataAnnotationsValidator/>
<div class="mt-8">
<Card>
<CardContent>
<FieldGroup>
<FormValidationSummary/>
<FieldSet>
<FieldGroup>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<Field>
<FieldLabel for="templateName">Name</FieldLabel>
<TextInputField
@bind-Value="Request.Name"
id="templateName"/>
</Field>
<Field>
<FieldLabel for="templateAuthor">Author</FieldLabel>
<TextInputField
@bind-Value="Request.Author"
id="templateAuthor"/>
</Field>
<Field>
<FieldLabel for="templateVersion">Version</FieldLabel>
<TextInputField
@bind-Value="Request.Version"
id="templateVersion"/>
</Field>
<Field ClassName="col-span-1 lg:col-span-2 xl:col-span-3">
<FieldLabel for="templateDescription">Description</FieldLabel>
<TextareaInputField
@bind-Value="Request.Description"
id="templateDescription"/>
</Field>
<Field>
<FieldLabel for="templateDonateUrl">Donate URL</FieldLabel>
<TextInputField
@bind-Value="Request.DonateUrl"
id="templateDonateUrl"/>
</Field>
<Field>
<FieldLabel for="templateUpdateUrl">Update URL</FieldLabel>
<TextInputField
@bind-Value="Request.UpdateUrl"
id="templateUpdateUrl"/>
</Field>
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
</div>
</EnhancedEditForm>
@code
{
private CreateTemplateDto Request = new();
private async Task<bool> OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore)
{
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/servers/templates",
Request,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"Template Creation",
$"Successfully created template {Request.Name}"
);
Navigation.NavigateTo("/admin/servers?tab=templates");
return true;
}
}

View File

@@ -0,0 +1,95 @@
@using Moonlight.Frontend.Helpers
@using MoonlightServers.Shared
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Switches
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>Create Docker Image</DialogTitle>
<DialogDescription>
Create a new docker image
</DialogDescription>
</DialogHeader>
<EnhancedEditForm @ref="Form" Model="Model" OnValidSubmit="OnValidSubmitAsync">
<FieldGroup>
<DataAnnotationsValidator/>
<FormValidationSummary/>
<FieldSet>
<Field>
<FieldLabel for="dockerImageDisplayName">Display Name</FieldLabel>
<TextInputField
@bind-Value="Model.DisplayName"
id="dockerImageDisplayName"/>
</Field>
<Field>
<FieldLabel for="dockerImageIdentifier">Image Name</FieldLabel>
<TextInputField
@bind-Value="Model.ImageName"
id="dockerImageIdentifier"/>
</Field>
<Field>
<FieldLabel for="dockerImageSkipPulling">Skip Pulling</FieldLabel>
<Switch id="dockerImageSkipPulling" @bind-Value="Model.SkipPulling" />
</Field>
</FieldSet>
</FieldGroup>
</EnhancedEditForm>
<DialogFooter>
<DialogClose>
<Slot>
<Button Type="button" Variant="ButtonVariant.Outline" @attributes="context">
Cancel
</Button>
</Slot>
</DialogClose>
<Button @onclick="() => Form.SubmitAsync()" Type="button">
Save
</Button>
</DialogFooter>
@code
{
[Parameter] public DetailedTemplateDto Template { get; set; }
[Parameter] public Func<Task> OnSubmit { get; set; }
private CreateDockerImageDto Model = new();
private EnhancedEditForm Form;
private async Task<bool> OnValidSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
var response = await HttpClient.PostAsJsonAsync(
$"api/admin/servers/templates/{Template.Id}/dockerImages",
Model,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Model, validationMessageStore);
return false;
}
await OnSubmit.Invoke();
await ToastService.SuccessAsync(
"Docker Image Creation",
$"Successfully created variable {Model.DisplayName}"
);
await CloseAsync();
return true;
}
}

View File

@@ -0,0 +1,103 @@
@using Moonlight.Frontend.Helpers
@using MoonlightServers.Shared
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
@inject ToastService ToastService
<DialogHeader>
<DialogTitle>Create Variable</DialogTitle>
<DialogDescription>
Create a new variable
</DialogDescription>
</DialogHeader>
<EnhancedEditForm @ref="Form" Model="Model" OnValidSubmit="OnValidSubmitAsync">
<FieldGroup>
<DataAnnotationsValidator/>
<FormValidationSummary/>
<FieldSet>
<Field>
<FieldLabel for="variableDisplayName">Display Name</FieldLabel>
<TextInputField
@bind-Value="Model.DisplayName"
id="variableDisplayName"/>
</Field>
<Field>
<FieldLabel for="variableDescription">Description</FieldLabel>
<TextareaInputField
@bind-Value="Model.Description"
id="variableDescription"/>
</Field>
<Field>
<FieldLabel for="variableKey">Key</FieldLabel>
<TextInputField
@bind-Value="Model.EnvName"
id="variableKey"/>
</Field>
<Field>
<FieldLabel for="variableDefaultValue">Default Value</FieldLabel>
<TextInputField
@bind-Value="Model.DefaultValue"
id="variableDefaultValue"/>
</Field>
</FieldSet>
</FieldGroup>
</EnhancedEditForm>
<DialogFooter>
<DialogClose>
<Slot>
<Button Type="button" Variant="ButtonVariant.Outline" @attributes="context">
Cancel
</Button>
</Slot>
</DialogClose>
<Button @onclick="() => Form.SubmitAsync()" Type="button">
Save
</Button>
</DialogFooter>
@code
{
[Parameter] public DetailedTemplateDto Template { get; set; }
[Parameter] public Func<Task> OnSubmit { get; set; }
private CreateVariableDto Model = new();
private EnhancedEditForm Form;
private async Task<bool> OnValidSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
var response = await HttpClient.PostAsJsonAsync(
$"api/admin/servers/templates/{Template.Id}/variables",
Model,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Model, validationMessageStore);
return false;
}
await OnSubmit.Invoke();
await ToastService.SuccessAsync(
"Variable Creation",
$"Successfully created variable {Model.DisplayName}"
);
await CloseAsync();
return true;
}
}

View File

@@ -0,0 +1,264 @@
@using System.Text.Json
@using LucideBlazor
@using Moonlight.Shared.Http.Responses
@using MoonlightServers.Shared
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Selects
@using ShadcnBlazor.Switches
@using ShadcnBlazor.Tabels
@inject HttpClient HttpClient
@inject ToastService ToastService
@inject AlertDialogService AlertDialogService
@inject DialogService DialogService
<LazyLoader @ref="LazyLoader" Load="LoadAsync">
<Card ClassName="mb-5">
<CardContent>
<FieldGroup>
<FieldSet>
<FieldGroup>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<Field>
<FieldLabel for="templateAllowUserDockerImageChange">Allow User Docker Image Change
</FieldLabel>
<Switch
@bind-Value="Request.AllowUserDockerImageChange"
id="templateAllowUserDockerImageChange"/>
</Field>
<Field>
<FieldLabel>Default Docker Image</FieldLabel>
<Select @bind-Value="DefaultDockerImageBinder">
<SelectTrigger>
<SelectValue Placeholder="Select a docker image" />
</SelectTrigger>
<SelectContent>
@foreach (var (id, dockerImage) in DockerImages)
{
<SelectItem Value="@id.ToString()">
@dockerImage.DisplayName
</SelectItem>
}
</SelectContent>
</Select>
</Field>
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
<div class="overflow-hidden rounded-md border bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>
Display Name
</TableHead>
<TableHead>
Identifier
</TableHead>
<TableHead>
Skip Pulling
</TableHead>
<TableHead ClassName="w-10"/>
</TableRow>
</TableHeader>
<TableBody>
@foreach (var (id, dockerImage) in DockerImages)
{
<TableRow
@key="dockerImage">
<TableCell>
<TextInputField
@bind-Value="dockerImage.DisplayName"
placeholder="Default Image"/>
</TableCell>
<TableCell>
<TextInputField
@bind-Value="dockerImage.ImageName"
placeholder="debian:latest"/>
</TableCell>
<TableCell>
<Switch @bind-Value="dockerImage.SkipPulling"/>
</TableCell>
<TableCell ClassName="text-right pr-4">
<Button @onclick="() => UpdateAsync(id, dockerImage)"
Size="ButtonSize.Icon"
Variant="ButtonVariant.Outline">
<SaveIcon/>
</Button>
<Button @onclick="() => DeleteAsync(id, dockerImage)"
Size="ButtonSize.Icon"
Variant="ButtonVariant.Destructive">
<Trash2Icon/>
</Button>
</TableCell>
</TableRow>
}
<TableRow>
<TableCell colspan="999999">
<div class="flex justify-end">
<Button
@onclick="AddAsync"
Variant="ButtonVariant.Outline"
Size="ButtonSize.Sm">
<PlusIcon/>
Add
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</LazyLoader>
@code
{
[Parameter] public DetailedTemplateDto Template { get; set; }
[Parameter] public UpdateTemplateDto Request { get; set; }
private readonly List<(int, UpdateDockerImageDto)> DockerImages = new();
private LazyLoader LazyLoader;
private string? DefaultDockerImageBinder
{
get => Request.DefaultDockerImageId?.ToString();
set
{
if (int.TryParse(value, out var intValue))
Request.DefaultDockerImageId = intValue;
else
Request.DefaultDockerImageId = null;
}
}
private async Task LoadAsync(LazyLoader _)
{
DockerImages.Clear();
var totalAmount = 0;
var currentIndex = 0;
const int pageSize = 50;
do
{
var dockerImages = await HttpClient.GetFromJsonAsync<PagedData<DockerImageDto>>(
$"api/admin/servers/templates/{Template.Id}/dockerImages?startIndex={currentIndex}&length={pageSize}"
);
if (dockerImages == null)
continue;
currentIndex += dockerImages.Data.Length;
totalAmount = dockerImages.TotalLength;
DockerImages.AddRange(dockerImages.Data.Select(x => (x.Id, new UpdateDockerImageDto()
{
DisplayName = x.DisplayName,
ImageName = x.ImageName,
SkipPulling = x.SkipPulling
})));
} while (DockerImages.Count < totalAmount);
}
private async Task DeleteAsync(int id, UpdateDockerImageDto dto)
{
await AlertDialogService.ConfirmDangerAsync(
"Docker Image Deletion",
$"Do you really want to delete the docker image {dto.DisplayName}? This cannot be undone",
async () =>
{
var response = await HttpClient.DeleteAsync($"api/admin/servers/templates/{Template.Id}/dockerImages/{id}");
response.EnsureSuccessStatusCode();
await ToastService.SuccessAsync(
"Docker Image Deletion",
$"Successfully deleted docker image {dto.DisplayName}"
);
await LazyLoader.ReloadAsync();
}
);
}
private async Task UpdateAsync(int id, UpdateDockerImageDto dto)
{
var response = await HttpClient.PutAsJsonAsync(
$"api/admin/servers/templates/{Template.Id}/dockerImages/{id}",
dto,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
try
{
var problemDetails = await response.Content.ReadFromJsonAsync<ProblemDetails>(
Moonlight.Shared.Http.SerializationContext.Default.Options
);
if (problemDetails == null)
{
// Fallback
response.EnsureSuccessStatusCode();
return;
}
if (problemDetails.Errors is { Count: > 0 })
{
var errorMessages = string.Join(
", ",
problemDetails.Errors.Select(x => $"{x.Key}: {x.Value.FirstOrDefault()}")
);
await ToastService.ErrorAsync(
"Docker Image Update",
$"{problemDetails.Detail ?? problemDetails.Title} {errorMessages}"
);
}
else
{
await ToastService.ErrorAsync(
"Docker Image Update",
$"{problemDetails.Detail ?? problemDetails.Title}"
);
}
return;
}
catch (JsonException)
{
// Fallback if unable to deserialize
response.EnsureSuccessStatusCode();
}
}
await ToastService.SuccessAsync(
"Docker Image Update",
$"Successfully updated docker image {dto.DisplayName}"
);
await LazyLoader.ReloadAsync();
}
private async Task AddAsync()
{
await DialogService.LaunchAsync<CreateDockerImageDialog>(parameters =>
{
parameters[nameof(CreateDockerImageDialog.Template)] = Template;
parameters[nameof(CreateDockerImageDialog.OnSubmit)] = async () => { await LazyLoader.ReloadAsync(); };
});
}
}

View File

@@ -0,0 +1,78 @@
@using LucideBlazor
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Tabels
@using ShadcnBlazor.Buttons
@inherits Editor<List<string>>
<div class="rounded-md bg-card shadow-sm border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
Online Text
</TableHead>
<TableHead ClassName="w-10"/>
</TableRow>
</TableHeader>
<TableBody>
@foreach (var command in Value)
{
<TableRow
@key="command">
<TableCell>
<TextInputField Value="@command"
ValueExpression="() => command" disabled/>
</TableCell>
<TableCell ClassName="text-right pr-4">
<Button @onclick="() => DeleteAsync(command)"
Size="ButtonSize.Icon"
Variant="ButtonVariant.Destructive">
<Trash2Icon/>
</Button>
</TableCell>
</TableRow>
}
<TableRow>
<TableCell colspan="999999">
<div class="flex justify-end gap-1">
<TextInputField ClassName="h-8"
@bind-Value="Input"
placeholder="Enter text..." />
<Button
@onclick="AddAsync"
Variant="ButtonVariant.Outline"
Size="ButtonSize.Sm">
<PlusIcon/>
Add Online Text
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
@code
{
private string Input;
private async Task DeleteAsync(string command)
{
Value.Remove(command);
await ValueChanged.InvokeAsync(Value);
}
private async Task AddAsync()
{
if(string.IsNullOrEmpty(Input))
return;
if(Value.Contains(Input))
return;
Value.Add(Input);
Input = "";
await ValueChanged.InvokeAsync(Value);
}
}

View File

@@ -0,0 +1,202 @@
@using LucideBlazor
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using MoonlightServers.Shared
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Tabels
@inject HttpClient HttpClient
@inject AlertDialogService AlertDialogService
@inject ToastService ToastService
@inject NavigationManager NavigationManager
@inject IAuthorizationService AuthorizationService
<InputFile OnChange="OnFileSelectedAsync" id="import-template" class="hidden" multiple accept=".yml,.yaml,.json"/>
<div class="flex flex-row justify-between mt-5">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Templates</h1>
<div class="text-muted-foreground">
Manage templates
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Outline">
<Slot>
<label for="import-template" @attributes="context">
<HardDriveUploadIcon/>
Import
</label>
</Slot>
</Button>
<Button>
<Slot Context="buttonCtx">
<a @attributes="buttonCtx" href="/admin/servers/templates/create"
data-disabled="@(!CreateAccess.Succeeded)">
<PlusIcon/>
Create
</a>
</Slot>
</Button>
</div>
</div>
<div class="mt-3">
<DataGrid @ref="Grid" TGridItem="TemplateDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
<PropertyColumn Field="u => u.Id"/>
<TemplateColumn IsFilterable="true" Identifier="@nameof(TemplateDto.Name)" Title="@nameof(TemplateDto.Name)">
<CellTemplate>
<TableCell>
<a class="text-primary" href="#"
@onclick="() => Edit(context)" @onclick:preventDefault>
@context.Name
</a>
</TableCell>
</CellTemplate>
</TemplateColumn>
<TemplateColumn Title="@nameof(TemplateDto.Description)" HeadClassName="hidden lg:table-cell">
<CellTemplate>
<TableCell ClassName="hidden lg:table-cell">
<div class="truncate max-w-md">@context.Description</div>
</TableCell>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Field="u => u.Author"
HeadClassName="hidden xl:table-cell"
CellClassName="hidden xl:table-cell"/>
<PropertyColumn Field="u => u.Version"
HeadClassName="hidden xl:table-cell"
CellClassName="hidden xl:table-cell"/>
<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="() => Export(context)">
Export
<DropdownMenuShortcut>
<HardDriveDownloadIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<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<TemplateDto> 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.Templates.Edit);
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Templates.Delete);
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Templates.Create);
}
private async Task<DataGridResponse<TemplateDto>> LoadAsync(DataGridRequest<TemplateDto> 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<TemplateDto>>(
$"api/admin/servers/templates{query}&filterOptions={filterOptions}",
SerializationContext.Default.Options
);
return new DataGridResponse<TemplateDto>(response!.Data, response.TotalLength);
}
private void Edit(TemplateDto context) => NavigationManager.NavigateTo($"/admin/servers/templates/{context.Id}");
private void Export(TemplateDto dto) => NavigationManager.NavigateTo($"api/admin/servers/templates/{dto.Id}/export", true);
private async Task DeleteAsync(TemplateDto context)
{
await AlertDialogService.ConfirmDangerAsync(
"Template Deletion",
$"Do you really want to delete the template {context.Name}? This cannot be undone.",
async () =>
{
var response = await HttpClient.DeleteAsync($"api/admin/servers/templates/{context.Id}");
response.EnsureSuccessStatusCode();
await Grid.RefreshAsync();
await ToastService.SuccessAsync(
"Template Deletion",
$"Successfully deleted template {context.Name}"
);
}
);
}
private async Task OnFileSelectedAsync(InputFileChangeEventArgs eventArgs)
{
var files = eventArgs.GetMultipleFiles();
foreach (var browserFile in files)
{
await using var contentStream = browserFile.OpenReadStream(browserFile.Size);
var response = await HttpClient.PostAsync(
"api/admin/servers/templates/import",
new StreamContent(contentStream)
);
response.EnsureSuccessStatusCode();
var importedTemplate = await response
.Content
.ReadFromJsonAsync<TemplateDto>(SerializationContext.Default.Options);
if (importedTemplate == null)
continue;
await Grid.RefreshAsync();
await ToastService.SuccessAsync("Template Import", $"Successfully imported template {importedTemplate.Name}");
}
}
}

View File

@@ -0,0 +1,16 @@
@using ShadcnBlazor.Extras.Editors
@inherits Editor<string>
<Editor @ref="Editor" Language="EditorLanguage.None" InitialValue="@Value" />
@code
{
private Editor Editor;
public async Task SubmitAsync()
{
Value = await Editor.GetValueAsync();
await ValueChanged.InvokeAsync(Value);
}
}

View File

@@ -0,0 +1,78 @@
@using LucideBlazor
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Tabels
@inherits Editor<List<MoonlightServers.Shared.Admin.Templates.UpdateStartupCommandDto>>
<div class="rounded-md bg-card shadow-sm border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
Display Name
</TableHead>
<TableHead>
Command
</TableHead>
<TableHead ClassName="w-10"/>
</TableRow>
</TableHeader>
<TableBody>
@foreach (var command in Value)
{
<TableRow
@key="command">
<TableCell>
<TextInputField @bind-Value="command.DisplayName"
placeholder="Default Command"/>
</TableCell>
<TableCell>
<TextInputField @bind-Value="command.Command"
placeholder="java -Xmx{{SERVER_MEMORY}} server.jar"/>
</TableCell>
<TableCell ClassName="text-right pr-4">
<Button @onclick="() => DeleteAsync(command)"
Size="ButtonSize.Icon"
Variant="ButtonVariant.Destructive">
<Trash2Icon/>
</Button>
</TableCell>
</TableRow>
}
<TableRow>
<TableCell colspan="999999">
<div class="flex justify-end">
<Button
@onclick="AddAsync"
Variant="ButtonVariant.Outline"
Size="ButtonSize.Sm">
<PlusIcon/>
Add Startup Command
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
@code
{
private async Task DeleteAsync(UpdateStartupCommandDto commandDto)
{
Value.Remove(commandDto);
await ValueChanged.InvokeAsync(Value);
}
private async Task AddAsync()
{
Value.Add(new UpdateStartupCommandDto()
{
Command = "Change me",
DisplayName = "Change me"
});
await ValueChanged.InvokeAsync(Value);
}
}

View File

@@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using MoonlightServers.Shared.Admin.Templates;
using Riok.Mapperly.Abstractions;
namespace MoonlightServers.Frontend.Admin.Templates;
[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 TemplateMapper
{
public static partial UpdateTemplateDto ToRequest(DetailedTemplateDto dto);
}

View File

@@ -0,0 +1,297 @@
@page "/admin/servers/templates/{Id:int}"
@using System.Net
@using System.Text.Json
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using MoonlightServers.Shared
@using MoonlightServers.Shared.Admin.Templates
@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
@using ShadcnBlazor.Tab
@inject HttpClient HttpClient
@inject NavigationManager Navigation
@inject ToastService ToastService
<LazyLoader Load="LoadAsync">
@if (Template == null)
{
<Empty>
<EmptyHeader>
<EmptyMedia Variant="EmptyMediaVariant.Icon">
<SearchIcon/>
</EmptyMedia>
<EmptyTitle>Template not found</EmptyTitle>
<EmptyDescription>
A template 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 Template</h1>
<div class="text-muted-foreground">
Update template @Template.Name
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/servers?tab=templates" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<SubmitButton>
<CheckIcon/>
Continue
</SubmitButton>
</div>
</div>
<div class="mt-8 space-y-5">
<DataAnnotationsValidator/>
<FormValidationSummary/>
<Tabs DefaultValue="meta">
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger Value="meta">
<IdCardIcon/>
Meta
</TabsTrigger>
<TabsTrigger Value="lifecycle">
<PlayIcon/>
Lifecycle
</TabsTrigger>
<TabsTrigger Value="installation">
<SquareTerminalIcon/>
Installation
</TabsTrigger>
<TabsTrigger Value="variables">
<VariableIcon/>
Variables
</TabsTrigger>
<TabsTrigger Value="dockerImages">
<ContainerIcon/>
Docker Images
</TabsTrigger>
<TabsTrigger Value="files">
<FileCogIcon/>
Files
</TabsTrigger>
</TabsList>
<TabsContent Value="meta">
<Card>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldGroup>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<Field>
<FieldLabel for="templateName">Name</FieldLabel>
<TextInputField
@bind-Value="Request.Name"
id="templateName"/>
</Field>
<Field>
<FieldLabel for="templateAuthor">Author</FieldLabel>
<TextInputField
@bind-Value="Request.Author"
id="templateAuthor"/>
</Field>
<Field>
<FieldLabel for="templateVersion">Version</FieldLabel>
<TextInputField
@bind-Value="Request.Version"
id="templateVersion"/>
</Field>
<Field ClassName="col-span-1 lg:col-span-2 xl:col-span-3">
<FieldLabel for="templateDescription">Description</FieldLabel>
<TextareaInputField
@bind-Value="Request.Description"
id="templateDescription"/>
</Field>
<Field>
<FieldLabel for="templateDonateUrl">Donate URL</FieldLabel>
<TextInputField
@bind-Value="Request.DonateUrl"
id="templateDonateUrl"/>
</Field>
<Field>
<FieldLabel for="templateUpdateUrl">Update URL</FieldLabel>
<TextInputField
@bind-Value="Request.UpdateUrl"
id="templateUpdateUrl"/>
</Field>
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
</TabsContent>
<TabsContent Value="lifecycle">
<Card>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldGroup>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<Field ClassName="col-span-1 lg:col-span-2">
<FieldLabel>Startup Commands</FieldLabel>
<StartupCommandEditor
@bind-Value="Request.LifecycleConfig.StartupCommands"/>
</Field>
<Field>
<FieldLabel for="templateStopCommand">Stop Command</FieldLabel>
<TextInputField
@bind-Value="Request.LifecycleConfig.StopCommand"
id="templateStopCommand"/>
</Field>
<Field ClassName="col-span-1 lg:col-span-2">
<FieldLabel>Online Texts</FieldLabel>
<OnlineTextEditor
@bind-Value="Request.LifecycleConfig.OnlineLogPatterns"/>
</Field>
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
</TabsContent>
<TabsContent Value="installation">
<Card>
<CardContent>
<FieldGroup>
<FieldSet>
<FieldGroup>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
<Field>
<FieldLabel for="templateInstallDockerImage">Docker Image
</FieldLabel>
<TextInputField
@bind-Value="Request.InstallationConfig.DockerImage"
id="templateInstallDockerImage"/>
</Field>
<Field>
<FieldLabel for="templateInstallShell">Shell</FieldLabel>
<TextInputField
@bind-Value="Request.InstallationConfig.Shell"
id="templateInstallShell"/>
</Field>
<Field ClassName="col-span-1 lg:col-span-2 xl:col-span-3">
<FieldLabel>Script</FieldLabel>
<ScriptEditor
@ref="ScriptEditor"
@bind-Value="Request.InstallationConfig.Script"/>
</Field>
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
</TabsContent>
<TabsContent Value="variables">
<VariableListEditor Template="Template"/>
</TabsContent>
<TabsContent Value="dockerImages">
<DockerImageListEditor Request="Request" Template="Template"/>
</TabsContent>
<TabsContent Value="files">
<ConfigFilesEditor FilesConfig="Request.FilesConfig"/>
</TabsContent>
</Tabs>
</div>
</EnhancedEditForm>
}
</LazyLoader>
@code
{
[Parameter] public int Id { get; set; }
private UpdateTemplateDto Request;
private DetailedTemplateDto? Template;
private ScriptEditor ScriptEditor;
private async Task LoadAsync(LazyLoader _)
{
// Meta
var metaResponse = await HttpClient.GetAsync($"api/admin/servers/templates/{Id}");
if (!metaResponse.IsSuccessStatusCode)
{
if (metaResponse.StatusCode == HttpStatusCode.NotFound)
return;
metaResponse.EnsureSuccessStatusCode();
return;
}
Template = await metaResponse.Content.ReadFromJsonAsync<DetailedTemplateDto>(
SerializationContext.Default.Options
);
if (Template == null)
return;
Request = TemplateMapper.ToRequest(Template);
}
private async Task<bool> OnSubmitAsync(EditContext context, ValidationMessageStore validationMessageStore)
{
await ScriptEditor.SubmitAsync();
var response = await HttpClient.PutAsJsonAsync(
$"/api/admin/servers/templates/{Id}",
Request,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"Template Update",
$"Successfully updated template {Request.Name}"
);
Navigation.NavigateTo("/admin/servers?tab=templates");
return true;
}
}

View File

@@ -0,0 +1,147 @@
@using LucideBlazor
@using Moonlight.Frontend.Helpers
@using MoonlightServers.Shared
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Accordions
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Forms
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@inject HttpClient HttpClient
@inject ToastService ToastService
@inject AlertDialogService AlertDialogService
<Accordion ClassName="-space-y-px" Type="AccordionType.Single">
<AccordionItem
ClassName="overflow-hidden border bg-card px-4 first:rounded-t-lg last:rounded-b-lg last:border-b"
Value="element">
<AccordionTrigger ClassName="hover:no-underline">
<div class="flex items-center gap-3">
<VariableIcon/>
<span class="text-left">@Request.DisplayName</span>
</div>
</AccordionTrigger>
<AccordionContent ClassName="ps-7">
<EnhancedEditForm Model="Request" OnValidSubmit="OnValidSubmitAsync">
<FieldGroup>
<DataAnnotationsValidator/>
<FormValidationSummary/>
<FieldSet>
<Field>
@{
var id = $"variableDisplayName{Variable.Id}";
}
<FieldLabel for="@id">Display Name</FieldLabel>
<TextInputField
@bind-Value="Request.DisplayName"
id="@id"/>
</Field>
<Field>
@{
var id = $"variableDescription{Variable.Id}";
}
<FieldLabel for="@id">Description</FieldLabel>
<TextareaInputField
@bind-Value="Request.Description"
id="@id"/>
</Field>
<Field>
@{
var id = $"variableKey{Variable.Id}";
}
<FieldLabel for="@id">Key</FieldLabel>
<TextInputField
@bind-Value="Request.EnvName"
id="@id"/>
</Field>
<Field>
@{
var id = $"variableDefaultValue{Variable.Id}";
}
<FieldLabel for="@id">Default Value</FieldLabel>
<TextInputField
@bind-Value="Request.DefaultValue"
id="@id"/>
</Field>
</FieldSet>
<Field Orientation="FieldOrientation.Horizontal" ClassName="justify-end">
<Button Variant="ButtonVariant.Destructive" @onclick="DeleteAsync">Delete</Button>
<SubmitButton>Save changes</SubmitButton>
</Field>
</FieldGroup>
</EnhancedEditForm>
</AccordionContent>
</AccordionItem>
</Accordion>
@code
{
[Parameter] public VariableDto Variable { get; set; }
[Parameter] public DetailedTemplateDto Template { get; set; }
[Parameter] public Func<Task> OnChanged { get; set; }
private UpdateVariableDto Request;
protected override void OnInitialized()
{
Request = new UpdateVariableDto()
{
DisplayName = Variable.DisplayName,
DefaultValue = Variable.DefaultValue,
EnvName = Variable.EnvName,
Description = Variable.Description
};
}
private async Task<bool> OnValidSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
var response = await HttpClient.PutAsJsonAsync(
$"api/admin/servers/templates/{Template.Id}/variables/{Variable.Id}",
Request,
SerializationContext.Default.Options
);
if (!response.IsSuccessStatusCode)
{
await ProblemDetailsHelper.HandleProblemDetailsAsync(response, Request, validationMessageStore);
return false;
}
await ToastService.SuccessAsync(
"Variable Update",
$"Successfully updated variable {Request.DisplayName}"
);
await OnChanged.Invoke();
return true;
}
private async Task DeleteAsync()
{
await AlertDialogService.ConfirmDangerAsync(
"Variable Deletion",
$"Do you really want to delete the variable {Variable.DisplayName}? This cannot be undone",
async () =>
{
var response = await HttpClient.DeleteAsync($"api/admin/servers/templates/{Template.Id}/variables/{Variable.Id}");
response.EnsureSuccessStatusCode();
await ToastService.SuccessAsync(
"Variable Deletion",
$"Variable {Variable.DisplayName} successfully deleted"
);
await OnChanged.Invoke();
}
);
}
}

View File

@@ -0,0 +1,77 @@
@using LucideBlazor
@using Moonlight.Shared.Http.Responses
@using MoonlightServers.Shared.Admin.Templates
@using ShadcnBlazor.Accordions
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.Dialogs
@using ShadcnBlazor.Fields
@using ShadcnBlazor.Inputs
@inject HttpClient HttpClient
@inject DialogService DialogService
<div class="flex flex-row justify-end mb-5">
<Button @onclick="LaunchCreateAsync">
<PlusIcon />
Create
</Button>
</div>
<LazyLoader @ref="LazyLoader" Load="LoadAsync">
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-8">
@foreach (var variable in Variables)
{
<VariableEditor OnChanged="() => LazyLoader.ReloadAsync()"
Template="Template"
Variable="variable"
@key="variable" />
}
</div>
</LazyLoader>
@code
{
[Parameter] public DetailedTemplateDto Template { get; set; }
private readonly List<VariableDto> Variables = new();
private LazyLoader LazyLoader;
private async Task LoadAsync(LazyLoader _)
{
Variables.Clear();
var totalAmount = 0;
var currentIndex = 0;
const int pageSize = 50;
do
{
var variables = await HttpClient.GetFromJsonAsync<PagedData<VariableDto>>(
$"api/admin/servers/templates/{Template.Id}/variables?startIndex={currentIndex}&length={pageSize}"
);
if (variables == null)
continue;
currentIndex += variables.Data.Length;
totalAmount = variables.TotalLength;
Variables.AddRange(variables.Data);
} while (Variables.Count < totalAmount);
}
private async Task LaunchCreateAsync()
{
await DialogService.LaunchAsync<CreateVariableDialog>(parameters =>
{
parameters[nameof(CreateVariableDialog.Template)] = Template;
parameters[nameof(CreateVariableDialog.OnSubmit)] = async () =>
{
await LazyLoader.ReloadAsync();
};
}
);
}
}

View File

@@ -15,6 +15,18 @@ public class PermissionProvider : IPermissionProvider
new Permission(Permissions.Nodes.Create, "Create", "Creating new nodes"), new Permission(Permissions.Nodes.Create, "Create", "Creating new nodes"),
new Permission(Permissions.Nodes.Edit, "Edit", "Editing nodes"), new Permission(Permissions.Nodes.Edit, "Edit", "Editing nodes"),
new Permission(Permissions.Nodes.Delete, "Delete", "Deleting nodes"), new Permission(Permissions.Nodes.Delete, "Delete", "Deleting nodes"),
]),
new PermissionCategory("Servers - Servers", typeof(ContainerIcon), [
new Permission(Permissions.Servers.View, "View", "Viewing all servers"),
new Permission(Permissions.Servers.Create, "Create", "Creating new servers"),
new Permission(Permissions.Servers.Edit, "Edit", "Editing servers"),
new Permission(Permissions.Servers.Delete, "Delete", "Deleting servers"),
]),
new PermissionCategory("Servers - Templates", typeof(ContainerIcon), [
new Permission(Permissions.Templates.View, "View", "Viewing all templates"),
new Permission(Permissions.Templates.Create, "Create", "Creating new templates"),
new Permission(Permissions.Templates.Edit, "Edit", "Editing templates"),
new Permission(Permissions.Templates.Delete, "Delete", "Deleting templates"),
]) ])
]); ]);
} }

View File

@@ -22,7 +22,7 @@ public sealed class SidebarProvider : ISidebarProvider
Name = "Servers", Name = "Servers",
IconType = typeof(ServerIcon), IconType = typeof(ServerIcon),
Path = "/admin/servers", Path = "/admin/servers",
Policy = Permissions.Nodes.View Policy = Permissions.Servers.View
} }
]); ]);
} }

View File

@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Moonlight.Frontend" Version="2.1.0"/> <PackageReference Include="Moonlight.Frontend" Version="2.1.0"/>
<PackageReference Include="ShadcnBlazor" Version="1.0.14"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class CreateDockerImageDto
{
[Required, MaxLength(30)]
public string DisplayName { get; set; } = string.Empty;
[Required, MaxLength(255)]
public string ImageName { get; set; } = string.Empty;
public bool SkipPulling { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class CreateTemplateDto
{
[Required, MaxLength(30)]
public string Name { get; set; } = string.Empty;
[Required, MaxLength(255)]
public string Description { get; set; } = string.Empty;
[Required, MaxLength(30)]
public string Author { get; set; } = string.Empty;
[Required, MaxLength(30)]
public string Version { get; set; } = string.Empty;
[MaxLength(2048)]
public string? UpdateUrl { get; set; }
[MaxLength(2048)]
public string? DonateUrl { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class CreateVariableDto
{
[Required, MaxLength(30)]
public string DisplayName { get; set; } = string.Empty;
[Required, MaxLength(255)]
public string Description { get; set; } = string.Empty;
[Required, MaxLength(60)]
public string EnvName { get; set; } = string.Empty;
[MaxLength(1024)]
public string? DefaultValue { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
namespace MoonlightServers.Shared.Admin.Templates;
public record DetailedTemplateDto(
int Id,
string Name,
string Description,
string Author,
string Version,
string? UpdateUrl,
string? DonateUrl,
FilesConfigDto FilesConfig,
LifecycleConfigDto LifecycleConfig,
InstallationConfigDto InstallationConfig,
MiscellaneousConfigDto MiscellaneousConfig,
bool AllowUserDockerImageChange,
int? DefaultDockerImageId
);

View File

@@ -0,0 +1,8 @@
namespace MoonlightServers.Shared.Admin.Templates;
public record DockerImageDto(
int Id,
string DisplayName,
string ImageName,
bool SkipPulling
);

View File

@@ -0,0 +1,20 @@
namespace MoonlightServers.Shared.Admin.Templates;
public class FilesConfigDto
{
public List<ConfigurationFileDto> ConfigurationFiles { get; set; } = new();
}
public class ConfigurationFileDto
{
public string Path { get; set; }
public string Parser { get; set; }
public List<ConfigurationFileMappingDto> Mappings { get; set; } = new();
}
public class ConfigurationFileMappingDto
{
public string Key { get; set; }
public string? Value { get; set; }
}

View File

@@ -0,0 +1,3 @@
namespace MoonlightServers.Shared.Admin.Templates;
public record InstallationConfigDto(string DockerImage, string Shell, string Script);

View File

@@ -0,0 +1,4 @@
namespace MoonlightServers.Shared.Admin.Templates;
public record LifecycleConfigDto(StartupCommandDto[] StartupCommands, string StopCommand, string[] OnlineLogPatterns);
public record StartupCommandDto(string DisplayName, string Command);

View File

@@ -0,0 +1,3 @@
namespace MoonlightServers.Shared.Admin.Templates;
public record MiscellaneousConfigDto(bool UseLegacyStartup);

View File

@@ -0,0 +1,11 @@
namespace MoonlightServers.Shared.Admin.Templates;
public record TemplateDto(
int Id,
string Name,
string Description,
string Author,
string Version,
string? UpdateUrl,
string? DonateUrl
);

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class UpdateDockerImageDto
{
[Required, MaxLength(30)]
public string DisplayName { get; set; } = string.Empty;
[Required, MaxLength(255)]
public string ImageName { get; set; } = string.Empty;
public bool SkipPulling { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class UpdateFilesConfigDto
{
public List<UpdateConfigurationFileDto> ConfigurationFiles { get; set; } = [];
}
public class UpdateConfigurationFileDto
{
[Required]
public string Path { get; set; } = string.Empty;
[Required]
public string Parser { get; set; } = string.Empty;
public List<UpdateConfigurationFileMappingDto> Mappings { get; set; } = [];
}
public class UpdateConfigurationFileMappingDto
{
[Required]
public string Key { get; set; } = string.Empty;
public string? Value { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class UpdateInstallationConfigDto
{
[Required, MaxLength(30)]
public string Shell { get; set; } = string.Empty;
[Required, MaxLength(255)]
public string DockerImage { get; set; } = string.Empty;
[Required]
public string Script { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class UpdateLifecycleConfigDto
{
public List<UpdateStartupCommandDto> StartupCommands { get; set; } = [];
[Required]
public string StopCommand { get; set; } = string.Empty;
public List<string> OnlineLogPatterns { get; set; } = [];
}
public class UpdateStartupCommandDto
{
[Required]
public string Command { get; set; } = string.Empty;
[Required]
public string DisplayName { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,6 @@
namespace MoonlightServers.Shared.Admin.Templates;
public class UpdateMiscellaneousConfigDto
{
public bool UseLegacyStartup { get; set; }
}

View File

@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class UpdateTemplateDto
{
[Required, MaxLength(30)]
public string Name { get; set; } = string.Empty;
[Required, MaxLength(255)]
public string Description { get; set; } = string.Empty;
[Required, MaxLength(30)]
public string Author { get; set; } = string.Empty;
[Required, MaxLength(30)]
public string Version { get; set; } = string.Empty;
[MaxLength(2048)]
public string? UpdateUrl { get; set; }
[MaxLength(2048)]
public string? DonateUrl { get; set; }
public UpdateFilesConfigDto FilesConfig { get; set; } = new();
public UpdateLifecycleConfigDto LifecycleConfig { get; set; } = new();
public UpdateInstallationConfigDto InstallationConfig { get; set; } = new();
public UpdateMiscellaneousConfigDto MiscellaneousConfig { get; set; } = new();
public bool AllowUserDockerImageChange { get; set; }
public int? DefaultDockerImageId { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace MoonlightServers.Shared.Admin.Templates;
public class UpdateVariableDto
{
[Required, MaxLength(30)]
public string DisplayName { get; set; } = string.Empty;
[Required, MaxLength(255)]
public string Description { get; set; }
[Required, MaxLength(60)]
public string EnvName { get; set; } = string.Empty;
[MaxLength(1024)]
public string? DefaultValue { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,9 @@
namespace MoonlightServers.Shared.Admin.Templates;
public record VariableDto(
int Id,
string DisplayName,
string Description,
string EnvName,
string DefaultValue
);

View File

@@ -14,4 +14,31 @@
<Folder Include="Client\" /> <Folder Include="Client\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Update="Admin\Templates\UpdateFilesConfigDto.cs">
<DependentUpon>UpdateTemplateDto.cs</DependentUpon>
</Compile>
<Compile Update="Admin\Templates\UpdateLifecycleConfigDto.cs">
<DependentUpon>UpdateTemplateDto.cs</DependentUpon>
</Compile>
<Compile Update="Admin\Templates\UpdateMiscellaneousConfigDto.cs">
<DependentUpon>UpdateTemplateDto.cs</DependentUpon>
</Compile>
<Compile Update="Admin\Templates\UpdateInstallationConfigDto.cs">
<DependentUpon>UpdateTemplateDto.cs</DependentUpon>
</Compile>
<Compile Update="Admin\Templates\FilesConfigDto.cs">
<DependentUpon>DetailedTemplateDto.cs</DependentUpon>
</Compile>
<Compile Update="Admin\Templates\InstallationConfigDto.cs">
<DependentUpon>DetailedTemplateDto.cs</DependentUpon>
</Compile>
<Compile Update="Admin\Templates\LifecycleConfigDto.cs">
<DependentUpon>DetailedTemplateDto.cs</DependentUpon>
</Compile>
<Compile Update="Admin\Templates\MiscellaneousConfigDto.cs">
<DependentUpon>DetailedTemplateDto.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project> </Project>

View File

@@ -2,7 +2,8 @@ namespace MoonlightServers.Shared;
public static class Permissions public static class Permissions
{ {
public const string Prefix = "Permissions:Servers."; public const string Prefix = "Permissions:MoonlightServers.";
public static class Nodes public static class Nodes
{ {
private const string Section = "Nodes"; private const string Section = "Nodes";
@@ -12,4 +13,24 @@ public static class Permissions
public const string Create = $"{Prefix}{Section}.{nameof(Create)}"; public const string Create = $"{Prefix}{Section}.{nameof(Create)}";
public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}"; public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}";
} }
public static class Servers
{
private const string Section = "Servers";
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)}";
}
public static class Templates
{
private const string Section = "Templates";
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

@@ -2,6 +2,7 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using MoonlightServers.Shared.Admin.Nodes; using MoonlightServers.Shared.Admin.Nodes;
using MoonlightServers.Shared.Admin.Templates;
namespace MoonlightServers.Shared; namespace MoonlightServers.Shared;
@@ -13,6 +14,23 @@ namespace MoonlightServers.Shared;
[JsonSerializable(typeof(NodeDto))] [JsonSerializable(typeof(NodeDto))]
[JsonSerializable(typeof(PagedData<NodeDto>))] [JsonSerializable(typeof(PagedData<NodeDto>))]
// - Templates
[JsonSerializable(typeof(CreateTemplateDto))]
[JsonSerializable(typeof(UpdateTemplateDto))]
[JsonSerializable(typeof(TemplateDto))]
[JsonSerializable(typeof(DetailedTemplateDto))]
[JsonSerializable(typeof(PagedData<TemplateDto>))]
[JsonSerializable(typeof(VariableDto))]
[JsonSerializable(typeof(PagedData<VariableDto>))]
[JsonSerializable(typeof(CreateVariableDto))]
[JsonSerializable(typeof(UpdateVariableDto))]
[JsonSerializable(typeof(DockerImageDto))]
[JsonSerializable(typeof(PagedData<DockerImageDto>))]
[JsonSerializable(typeof(CreateDockerImageDto))]
[JsonSerializable(typeof(UpdateDockerImageDto))]
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
public partial class SerializationContext : JsonSerializerContext public partial class SerializationContext : JsonSerializerContext