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>
<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>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

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

View File

@@ -7,13 +7,13 @@
@source "../bin/Moonlight.Frontend/*.map";
@source "../../../Moonlight.Api/**/*.razor";
@source "../../../Moonlight.Api/**/*.cs";
@source "../../../Moonlight.Api/**/*.html";
@source "../../../MoonlightServers.Api/**/*.razor";
@source "../../../MoonlightServers.Api/**/*.cs";
@source "../../../MoonlightServers.Api/**/*.html";
@source "../../../Moonlight.Frontend/**/*.razor";
@source "../../../Moonlight.Frontend/**/*.cs";
@source "../../../Moonlight.Frontend/**/*.html";
@source "../../../MoonlightServers.Frontend/**/*.razor";
@source "../../../MoonlightServers.Frontend/**/*.cs";
@source "../../../MoonlightServers.Frontend/**/*.html";
@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 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;
public DataContext(IOptions<DatabaseOptions> options)
@@ -34,5 +37,25 @@ public class DataContext : DbContext
modelBuilder.HasDefaultSchema("servers");
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)
{
Logger.LogDebug("No pending migrations found");
Logger.LogTrace("No pending migrations found");
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 />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -18,7 +19,7 @@ namespace MoonlightServers.Api.Infrastructure.Database.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("servers")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -61,6 +62,250 @@ namespace MoonlightServers.Api.Infrastructure.Database.Migrations
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

@@ -12,6 +12,8 @@
</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">
<ExcludeAssets>content;contentfiles</ExcludeAssets>
</PackageReference>

View File

@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moonlight.Api;
using MoonlightServers.Api.Admin.Nodes;
using MoonlightServers.Api.Admin.Templates;
using MoonlightServers.Api.Infrastructure.Configuration;
using MoonlightServers.Api.Infrastructure.Database;
using MoonlightServers.Api.Infrastructure.Implementations.NodeToken;
@@ -29,6 +30,10 @@ public class Startup : MoonlightPlugin
builder.Services.AddDbContext<DataContext>();
builder.Services.AddHostedService<DbMigrationService>();
builder.Services.AddScoped<TemplateTransferService>();
builder.Services.AddScoped<PterodactylEggImportService>();
builder.Services.AddScoped<PelicanEggImportService>();
builder.Services.AddSingleton<NodeService>();
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">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/servers/nodes" @attributes="context">
<a href="/admin/servers?tab=nodes" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
@@ -50,7 +50,7 @@
<FieldSet>
<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>
<FieldLabel for="nodeName">Name</FieldLabel>
<TextInputField
@@ -98,7 +98,7 @@
$"Successfully created node {Request.Name}"
);
Navigation.NavigateTo("/admin/servers/nodes");
Navigation.NavigateTo("/admin/servers?tab=nodes");
return true;
}
}

View File

@@ -47,7 +47,7 @@
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/admin/servers/nodes" @attributes="context">
<a href="/admin/servers?tab=nodes" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
@@ -70,7 +70,7 @@
<FieldSet>
<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>
<FieldLabel for="nodeName">Name</FieldLabel>
<TextInputField
@@ -148,7 +148,7 @@
$"Successfully updated node {Request.Name}"
);
Navigation.NavigateTo("/admin/servers/nodes");
Navigation.NavigateTo("/admin/servers?tab=nodes");
return true;
}
}

View File

@@ -1,5 +1,3 @@
@page "/admin/servers/nodes"
@using LucideBlazor
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@@ -15,7 +13,6 @@
@inject HttpClient HttpClient
@inject AlertDialogService AlertDialogService
@inject DialogService DialogService
@inject ToastService ToastService
@inject NavigationManager NavigationManager
@inject IAuthorizationService AuthorizationService
@@ -61,7 +58,6 @@
</CellTemplate>
</TemplateColumn>
<PropertyColumn Title="HTTP Endpoint"
Identifier="@nameof(NodeDto.HttpEndpointUrl)"
Field="u => u.HttpEndpointUrl"/>
<TemplateColumn>
<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.Edit, "Edit", "Editing 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",
IconType = typeof(ServerIcon),
Path = "/admin/servers",
Policy = Permissions.Nodes.View
Policy = Permissions.Servers.View
}
]);
}

View File

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

View File

@@ -2,7 +2,8 @@ namespace MoonlightServers.Shared;
public static class Permissions
{
public const string Prefix = "Permissions:Servers.";
public const string Prefix = "Permissions:MoonlightServers.";
public static class 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 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 Moonlight.Shared.Http.Responses;
using MoonlightServers.Shared.Admin.Nodes;
using MoonlightServers.Shared.Admin.Templates;
namespace MoonlightServers.Shared;
@@ -13,6 +14,23 @@ namespace MoonlightServers.Shared;
[JsonSerializable(typeof(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)]
public partial class SerializationContext : JsonSerializerContext