Refactor code structure for improved readability and maintainability

This commit is contained in:
Matt Batchelder
2026-02-12 15:24:25 -05:00
commit 29b8c23dbb
102 changed files with 65695 additions and 0 deletions

58
.gitignore vendored Normal file
View File

@@ -0,0 +1,58 @@
# .gitignore for C#/.NET projects on macOS
# Generated for Visual Studio, Rider, and dotnet CLI workflows
# Visual Studio
.vs/
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Bb]in/
[Oo]bj/
build/
publish/
artifacts/
# Rider
.idea/
# Resharper
_ReSharper*/
*.DotSettings.user
# NuGet
*.nupkg
packages/
project.lock.json
# Dotnet
*.db
*.db-journal
secrets.json
dotnet_user_secrets
# Logs
*.log
TestResults/
# OS generated files
.DS_Store
.AppleDouble
._*
Icon
# Editor directories and files
.vscode/
*.code-workspace
# Temporary files
*~
*.tmp
# Docker
docker-compose.override.yml
# Ignore appsettings development files (if you keep secrets locally)
appsettings.Development.json

22
OTSSignsOrchestrator.sln Normal file
View File

@@ -0,0 +1,22 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator", "OTSSignsOrchestrator\OTSSignsOrchestrator.csproj", "{67B192E6-375B-41D7-9537-E66DE1D057C5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{67B192E6-375B-41D7-9537-E66DE1D057C5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,8 @@
bin/
obj/
logs/
*.db
*.db-shm
*.db-wal
template-cache/
appsettings.*.local.json

View File

@@ -0,0 +1,76 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
namespace OTSSignsOrchestrator.API;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly string _adminToken;
private readonly ILogger<AuthController> _logger;
public AuthController(IOptions<Configuration.AuthenticationOptions> authOptions, ILogger<AuthController> logger)
{
_adminToken = authOptions.Value.LocalAdminToken;
_logger = logger;
}
/// <summary>
/// Verify a local admin token and issue a cookie.
/// </summary>
[HttpPost("verify-token")]
[AllowAnonymous]
public async Task<IActionResult> VerifyToken([FromBody] TokenLoginDto dto)
{
if (string.IsNullOrEmpty(_adminToken))
return BadRequest(new { message = "Local admin token not configured." });
if (!string.Equals(dto.Token, _adminToken, StringComparison.Ordinal))
{
_logger.LogWarning("Invalid admin token attempt from {IP}", HttpContext.Connection.RemoteIpAddress);
return Unauthorized(new { message = "Invalid token." });
}
var claims = new List<Claim>
{
new(ClaimTypes.Name, "LocalAdmin"),
new(ClaimTypes.Role, AppConstants.AdminRole),
new("auth_method", "admin_token")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddHours(8)
});
_logger.LogInformation("Admin token login from {IP}", HttpContext.Connection.RemoteIpAddress);
return Ok(new { valid = true, message = "Authenticated as LocalAdmin." });
}
[HttpGet("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok(new { message = "Logged out." });
}
}
public class TokenLoginDto
{
public string Token { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,134 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.API;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class InstancesController : ControllerBase
{
private readonly InstanceService _instanceService;
private readonly ILogger<InstancesController> _logger;
public InstancesController(InstanceService instanceService, ILogger<InstancesController> logger)
{
_instanceService = instanceService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] string? filter = null)
{
var (items, totalCount) = await _instanceService.ListInstancesAsync(page, pageSize, filter);
return Ok(new
{
items = items.Select(i => new
{
i.Id,
i.CustomerName,
i.StackName,
i.CmsServerName,
i.HostHttpPort,
Status = i.Status.ToString(),
XiboApiStatus = i.XiboApiTestStatus.ToString(),
i.CreatedAt,
i.UpdatedAt
}),
totalCount,
page,
pageSize
});
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var instance = await _instanceService.GetInstanceAsync(id);
if (instance == null) return NotFound();
return Ok(new
{
instance.Id,
instance.CustomerName,
instance.StackName,
instance.CmsServerName,
instance.HostHttpPort,
instance.ThemeHostPath,
instance.LibraryHostPath,
instance.SmtpServer,
instance.SmtpUsername,
instance.Constraints,
instance.TemplateRepoUrl,
instance.TemplateLastFetch,
Status = instance.Status.ToString(),
instance.XiboUsername,
// Never return XiboPassword
XiboApiStatus = instance.XiboApiTestStatus.ToString(),
instance.XiboApiTestedAt,
instance.CreatedAt,
instance.UpdatedAt
});
}
[HttpPost]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Create([FromBody] CreateInstanceDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var userId = User.FindFirst(ClaimTypes.Name)?.Value;
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await _instanceService.CreateInstanceAsync(dto, userId, ip);
return result.Success
? Ok(result)
: StatusCode(500, result);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateInstanceDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var userId = User.FindFirst(ClaimTypes.Name)?.Value;
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await _instanceService.UpdateInstanceAsync(id, dto, userId, ip);
return result.Success
? Ok(result)
: StatusCode(500, result);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Delete(Guid id, [FromQuery] bool retainSecrets = false, [FromQuery] bool clearXiboCreds = true)
{
var userId = User.FindFirst(ClaimTypes.Name)?.Value;
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await _instanceService.DeleteInstanceAsync(id, retainSecrets, clearXiboCreds, userId, ip);
return result.Success
? Ok(result)
: StatusCode(500, result);
}
[HttpPost("{id:guid}/test-xibo-connection")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> TestXiboConnection(Guid id)
{
var result = await _instanceService.TestXiboConnectionAsync(id);
return Ok(result);
}
}

View File

@@ -0,0 +1,149 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Data;
namespace OTSSignsOrchestrator.API;
[ApiController]
[Route("api/admin/[controller]")]
[Authorize(Roles = AppConstants.AdminRole)]
public class LogsController : ControllerBase
{
private readonly FileLoggingOptions _loggingOptions;
private readonly IWebHostEnvironment _env;
private readonly ILogger<LogsController> _logger;
private readonly XiboContext _db;
public LogsController(
IOptions<FileLoggingOptions> loggingOptions,
IWebHostEnvironment env,
ILogger<LogsController> logger,
XiboContext db)
{
_loggingOptions = loggingOptions.Value;
_env = env;
_logger = logger;
_db = db;
}
/// <summary>
/// Tail recent log lines with optional filters.
/// </summary>
[HttpGet]
public IActionResult GetLogs([FromQuery] int lines = 100, [FromQuery] string? filter = null, [FromQuery] string? level = null)
{
var logPath = _loggingOptions.Path;
if (!Path.IsPathRooted(logPath))
logPath = Path.Combine(_env.ContentRootPath, logPath);
if (!Directory.Exists(logPath))
return Ok(new { lines = Array.Empty<string>(), path = logPath, message = "Log directory not found." });
// Find latest log file
var logFiles = Directory.GetFiles(logPath, "app-*.log")
.OrderByDescending(f => f)
.ToList();
if (logFiles.Count == 0)
return Ok(new { lines = Array.Empty<string>(), path = logPath, message = "No log files found." });
var latestFile = logFiles[0];
var allLines = ReadLastLines(latestFile, lines * 2); // Read extra for filtering
// Apply filters
if (!string.IsNullOrWhiteSpace(level))
{
allLines = allLines.Where(l =>
l.Contains($"[{level.ToUpperInvariant().PadRight(3)}]", StringComparison.OrdinalIgnoreCase) ||
l.Contains($"[{level}]", StringComparison.OrdinalIgnoreCase)).ToList();
}
if (!string.IsNullOrWhiteSpace(filter))
{
allLines = allLines.Where(l =>
l.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
}
var result = allLines.TakeLast(lines).ToList();
var fileInfo = new FileInfo(latestFile);
return Ok(new
{
lines = result,
path = latestFile,
sizeBytes = fileInfo.Length,
lastModified = fileInfo.LastWriteTimeUtc
});
}
/// <summary>
/// Download the latest log file.
/// </summary>
[HttpGet("download")]
public IActionResult DownloadLog()
{
var logPath = _loggingOptions.Path;
if (!Path.IsPathRooted(logPath))
logPath = Path.Combine(_env.ContentRootPath, logPath);
var logFiles = Directory.GetFiles(logPath, "app-*.log")
.OrderByDescending(f => f)
.ToList();
if (logFiles.Count == 0)
return NotFound("No log files found.");
var latestFile = logFiles[0];
var stream = new FileStream(latestFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
return File(stream, "text/plain", Path.GetFileName(latestFile));
}
/// <summary>
/// Get recent operation logs from the database.
/// </summary>
[HttpGet("operations")]
public async Task<IActionResult> GetOperations([FromQuery] int count = 50)
{
var ops = await _db.OperationLogs
.Include(o => o.Instance)
.OrderByDescending(o => o.Timestamp)
.Take(Math.Min(count, 200))
.Select(o => new
{
o.Id,
o.Operation,
o.Status,
o.Message,
o.Timestamp,
o.DurationMs,
o.UserId,
StackName = o.Instance != null ? o.Instance.StackName : null
})
.ToListAsync();
return Ok(ops);
}
private static List<string> ReadLastLines(string filePath, int lineCount)
{
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
var lines = new List<string>();
string? line;
while ((line = reader.ReadLine()) != null)
{
lines.Add(line);
}
return lines.TakeLast(lineCount).ToList();
}
catch
{
return new List<string>();
}
}
}

View File

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.API;
[ApiController]
[Route("api")]
public class OidcProvidersController : ControllerBase
{
private readonly OidcProviderService _providerService;
private readonly ILogger<OidcProvidersController> _logger;
public OidcProvidersController(OidcProviderService providerService, ILogger<OidcProvidersController> logger)
{
_providerService = providerService;
_logger = logger;
}
/// <summary>
/// List active OIDC providers (no auth required — used by login page).
/// </summary>
[HttpGet("idp-providers")]
[AllowAnonymous]
public async Task<IActionResult> ListActive()
{
var providers = await _providerService.GetActiveProvidersAsync();
return Ok(new
{
items = providers.Select(p => new
{
p.Id,
p.Name,
p.IsEnabled,
p.IsPrimary
})
});
}
[HttpGet("admin/idp-providers")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> ListAll()
{
var providers = await _providerService.GetAllProvidersAsync();
return Ok(new
{
items = providers.Select(p => new
{
p.Id,
p.Name,
p.Authority,
p.ClientId,
p.Audience,
p.IsEnabled,
p.IsPrimary,
p.CreatedAt,
p.UpdatedAt
// Never return ClientSecret
})
});
}
[HttpPost("admin/idp-providers")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Create([FromBody] CreateOidcProviderDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var provider = await _providerService.CreateProviderAsync(dto);
return Ok(new { provider.Id, provider.Name, provider.CreatedAt });
}
[HttpPut("admin/idp-providers/{id:guid}")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateOidcProviderDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var provider = await _providerService.UpdateProviderAsync(id, dto);
return Ok(new { provider.Id, provider.Name, provider.UpdatedAt });
}
[HttpDelete("admin/idp-providers/{id:guid}")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Delete(Guid id)
{
await _providerService.DeleteProviderAsync(id);
return Ok(new { success = true, message = "Provider deleted." });
}
[HttpPost("admin/idp-providers/{id:guid}/test")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Test(Guid id)
{
var provider = await _providerService.GetProviderAsync(id);
if (provider == null) return NotFound();
var (isValid, message) = await _providerService.TestConnectionAsync(provider);
return Ok(new { isValid, message });
}
}

View File

@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.API;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class SecretsController : ControllerBase
{
private readonly XiboContext _db;
private readonly DockerSecretsService _secretsService;
private readonly ILogger<SecretsController> _logger;
public SecretsController(XiboContext db, DockerSecretsService secretsService, ILogger<SecretsController> logger)
{
_db = db;
_secretsService = secretsService;
_logger = logger;
}
/// <summary>
/// List secret metadata (names and dates, NEVER values).
/// </summary>
[HttpGet]
public async Task<IActionResult> List()
{
var dbSecrets = await _db.SecretMetadata
.OrderBy(s => s.Name)
.ToListAsync();
return Ok(dbSecrets.Select(s => new
{
s.Id,
s.Name,
s.IsGlobal,
s.CustomerName,
s.CreatedAt,
s.LastRotatedAt
}));
}
/// <summary>
/// Rotate a secret (delete + recreate with new value).
/// </summary>
[HttpPost("{name}/rotate")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Rotate(string name, [FromBody] RotateSecretDto dto)
{
if (string.IsNullOrWhiteSpace(dto.NewValue))
return BadRequest(new { message = "NewValue is required." });
_logger.LogInformation("Rotating secret: {SecretName}", name);
var (created, secretId) = await _secretsService.EnsureSecretAsync(name, dto.NewValue, rotate: true);
// Update DB metadata
var meta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
if (meta != null)
{
meta.LastRotatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
return Ok(new { success = true, message = $"Secret '{name}' rotated." });
}
/// <summary>
/// Delete a secret.
/// </summary>
[HttpDelete("{name}")]
[Authorize(Roles = AppConstants.AdminRole)]
public async Task<IActionResult> Delete(string name)
{
_logger.LogInformation("Deleting secret: {SecretName}", name);
await _secretsService.DeleteSecretAsync(name);
var meta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
if (meta != null)
{
_db.SecretMetadata.Remove(meta);
await _db.SaveChangesAsync();
}
return Ok(new { success = true, message = $"Secret '{name}' deleted." });
}
}
public class RotateSecretDto
{
public string NewValue { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["OTSSignsOrchestrator.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<AuthorizeView>
<Authorized>
<span class="me-3">@context.User.Identity?.Name</span>
<a href="api/auth/logout">Logout</a>
</Authorized>
<NotAuthorized>
<a href="login">Login</a>
</NotAuthorized>
</AuthorizeView>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,98 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,46 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">OTS Signs Orchestrator</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
</NavLink>
</div>
<AuthorizeView Roles="Admin">
<div class="nav-item px-3">
<NavLink class="nav-link" href="instances/create">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> New Instance
</NavLink>
</div>
</AuthorizeView>
<AuthorizeView Roles="Admin">
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/oidc-providers">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> OIDC Providers
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/secrets">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Secrets
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/logs">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Logs
</NavLink>
</div>
</AuthorizeView>
</nav>
</div>

View File

@@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,198 @@
@page "/admin/logs"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
@inject HttpClient Http
@inject NavigationManager Navigation
<PageTitle>Logs - Admin - OTS Signs Orchestrator</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h3>Application Logs</h3>
<button class="btn btn-outline-primary" @onclick="DownloadLog">Download Current Log</button>
</div>
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Level</label>
<select @bind="level" class="form-select">
<option value="">All</option>
<option value="Information">Information</option>
<option value="Warning">Warning</option>
<option value="Error">Error</option>
<option value="Debug">Debug</option>
</select>
</div>
<div class="col-md-5">
<label class="form-label">Filter text</label>
<input @bind="filter" class="form-control" placeholder="Search in log messages..." />
</div>
<div class="col-md-2">
<label class="form-label">Lines</label>
<select @bind="tailLines" class="form-select">
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" @onclick="LoadLogs" disabled="@loading">
@(loading ? "Loading..." : "Refresh")
</button>
</div>
</div>
@if (errorMessage != null)
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="card">
<div class="card-body p-0">
<pre class="bg-dark text-light p-3 m-0" style="max-height: 600px; overflow-y: auto; font-size: 0.8rem; white-space: pre-wrap; word-break: break-all;">@(logContent ?? "Click Refresh to load logs.")</pre>
</div>
</div>
<hr />
<h5>Operation Logs</h5>
<p class="text-muted">Recent deployment and management operations recorded in the database.</p>
@if (operationLogs == null)
{
<p>Loading operation logs...</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Time</th>
<th>Operation</th>
<th>Instance</th>
<th>Status</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@foreach (var log in operationLogs)
{
<tr class="@GetRowClass(log.Status)">
<td><small>@log.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</small></td>
<td>@log.Operation</td>
<td>@(log.StackName ?? "—")</td>
<td>
<span class="badge @GetStatusBadge(log.Status)">@log.Status</span>
</td>
<td><small>@TruncateMessage(log.Message)</small></td>
</tr>
}
</tbody>
</table>
}
@code {
private string level = "";
private string filter = "";
private int tailLines = 100;
private string? logContent;
private string? errorMessage;
private bool loading;
private List<OperationLogEntry>? operationLogs;
protected override async Task OnInitializedAsync()
{
await LoadOperationLogs();
}
private async Task LoadLogs()
{
loading = true;
errorMessage = null;
try
{
var query = $"/api/admin/logs?lines={tailLines}";
if (!string.IsNullOrWhiteSpace(level)) query += $"&level={level}";
if (!string.IsNullOrWhiteSpace(filter)) query += $"&filter={Uri.EscapeDataString(filter)}";
var response = await Http.GetAsync(query);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadFromJsonAsync<LogResponse>();
logContent = json?.Lines != null ? string.Join("\n", json.Lines) : "No log entries.";
}
else
{
errorMessage = $"Failed to load logs: {response.StatusCode}";
}
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
loading = false;
}
}
private async Task LoadOperationLogs()
{
try
{
var response = await Http.GetAsync("/api/admin/logs/operations?count=50");
if (response.IsSuccessStatusCode)
{
operationLogs = await response.Content.ReadFromJsonAsync<List<OperationLogEntry>>() ?? new();
}
else
{
operationLogs = new();
}
}
catch
{
operationLogs = new();
}
}
private async Task DownloadLog()
{
await Task.CompletedTask;
Navigation.NavigateTo("/api/admin/logs/download", forceLoad: true);
}
private static string GetStatusBadge(string status) => status switch
{
"Success" => "bg-success",
"Failure" => "bg-danger",
"Pending" => "bg-warning text-dark",
_ => "bg-secondary"
};
private static string GetRowClass(string status) => status switch
{
"Failure" => "table-danger",
_ => ""
};
private static string TruncateMessage(string? msg) =>
msg?.Length > 120 ? msg[..120] + "..." : msg ?? "";
private class LogResponse
{
public List<string>? Lines { get; set; }
public string? Path { get; set; }
}
private class OperationLogEntry
{
public Guid Id { get; set; }
public string Operation { get; set; } = "";
public string Status { get; set; } = "";
public string? Message { get; set; }
public DateTime Timestamp { get; set; }
public long? DurationMs { get; set; }
public string? UserId { get; set; }
public string? StackName { get; set; }
}
}

View File

@@ -0,0 +1,289 @@
@page "/admin/oidc-providers"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
@inject OidcProviderService ProviderSvc
@inject NavigationManager Navigation
<PageTitle>OIDC Providers - Admin - OTS Signs Orchestrator</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h3>OIDC Providers</h3>
<button class="btn btn-primary" @onclick="ShowAddForm">+ Add Provider</button>
</div>
@if (errorMessage != null)
{
<div class="alert alert-danger alert-dismissible">
@errorMessage
<button type="button" class="btn-close" @onclick="() => errorMessage = null"></button>
</div>
}
@if (successMessage != null)
{
<div class="alert alert-success alert-dismissible">
@successMessage
<button type="button" class="btn-close" @onclick="() => successMessage = null"></button>
</div>
}
@if (providers == null)
{
<p>Loading...</p>
}
else if (providers.Count == 0)
{
<div class="alert alert-info">No OIDC providers configured. Add one to enable external authentication.</div>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Authority</th>
<th>Client ID</th>
<th>Status</th>
<th>Primary</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var p in providers)
{
<tr>
<td>@p.Name</td>
<td><small>@p.Authority</small></td>
<td><code>@p.ClientId</code></td>
<td>
<span class="badge @(p.IsEnabled ? "bg-success" : "bg-secondary")">
@(p.IsEnabled ? "Enabled" : "Disabled")
</span>
</td>
<td>
@if (p.IsPrimary)
{
<span class="badge bg-primary">Primary</span>
}
</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => StartEdit(p)">Edit</button>
<button class="btn btn-sm btn-outline-info me-1" @onclick="() => TestProvider(p.Id)" disabled="@testingId.HasValue">Test</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteProvider(p.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
@* Add/Edit Modal *@
@if (showForm)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(editingId.HasValue ? "Edit Provider" : "Add Provider")</h5>
<button type="button" class="btn-close" @onclick="CloseForm"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input @bind="formName" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Authority (issuer URL)</label>
<input @bind="formAuthority" class="form-control" placeholder="https://login.example.com" />
</div>
<div class="mb-3">
<label class="form-label">Client ID</label>
<input @bind="formClientId" class="form-control" />
</div>
<div class="mb-3">
<label class="form-label">Client Secret @(editingId.HasValue ? "(leave blank to keep)" : "")</label>
<input @bind="formClientSecret" class="form-control" type="password" />
</div>
<div class="mb-3">
<label class="form-label">Audience (optional)</label>
<input @bind="formAudience" class="form-control" />
</div>
<div class="form-check mb-2">
<input @bind="formIsEnabled" class="form-check-input" type="checkbox" id="chkEnabled" />
<label class="form-check-label" for="chkEnabled">Enabled</label>
</div>
<div class="form-check mb-2">
<input @bind="formIsPrimary" class="form-check-input" type="checkbox" id="chkPrimary" />
<label class="form-check-label" for="chkPrimary">Primary (used on login page)</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="CloseForm">Cancel</button>
<button class="btn btn-primary" @onclick="SaveProvider" disabled="@formSaving">
@(formSaving ? "Saving..." : "Save")
</button>
</div>
</div>
</div>
</div>
}
@code {
private List<OidcProvider>? providers;
private string? errorMessage;
private string? successMessage;
private Guid? testingId;
// Form state
private bool showForm;
private Guid? editingId;
private string formName = "";
private string formAuthority = "";
private string formClientId = "";
private string formClientSecret = "";
private string? formAudience;
private bool formIsEnabled = true;
private bool formIsPrimary;
private bool formSaving;
protected override async Task OnInitializedAsync()
{
await LoadProviders();
}
private async Task LoadProviders()
{
providers = await ProviderSvc.GetAllProvidersAsync();
}
private void ShowAddForm()
{
editingId = null;
formName = "";
formAuthority = "";
formClientId = "";
formClientSecret = "";
formAudience = null;
formIsEnabled = true;
formIsPrimary = false;
showForm = true;
}
private void StartEdit(OidcProvider p)
{
editingId = p.Id;
formName = p.Name;
formAuthority = p.Authority;
formClientId = p.ClientId;
formClientSecret = "";
formAudience = p.Audience;
formIsEnabled = p.IsEnabled;
formIsPrimary = p.IsPrimary;
showForm = true;
}
private void CloseForm()
{
showForm = false;
editingId = null;
}
private async Task SaveProvider()
{
formSaving = true;
errorMessage = null;
try
{
if (editingId.HasValue)
{
var dto = new UpdateOidcProviderDto
{
Name = formName,
Authority = formAuthority,
ClientId = formClientId,
ClientSecret = string.IsNullOrWhiteSpace(formClientSecret) ? null : formClientSecret,
Audience = formAudience,
IsEnabled = formIsEnabled,
IsPrimary = formIsPrimary
};
await ProviderSvc.UpdateProviderAsync(editingId.Value, dto);
successMessage = "Provider updated.";
}
else
{
if (string.IsNullOrWhiteSpace(formClientSecret))
{
errorMessage = "Client secret is required for new providers.";
return;
}
var dto = new CreateOidcProviderDto
{
Name = formName,
Authority = formAuthority,
ClientId = formClientId,
ClientSecret = formClientSecret,
Audience = formAudience,
IsEnabled = formIsEnabled,
IsPrimary = formIsPrimary
};
await ProviderSvc.CreateProviderAsync(dto);
successMessage = "Provider created.";
}
await LoadProviders();
CloseForm();
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
formSaving = false;
}
}
private async Task TestProvider(Guid id)
{
testingId = id;
errorMessage = null;
try
{
var provider = await ProviderSvc.GetProviderAsync(id);
if (provider == null)
{
errorMessage = "Provider not found.";
return;
}
var (isValid, message) = await ProviderSvc.TestConnectionAsync(provider);
if (isValid)
successMessage = $"Provider connectivity OK: {message}";
else
errorMessage = $"Provider connectivity test failed: {message}";
}
catch (Exception ex)
{
errorMessage = $"Test failed: {ex.Message}";
}
finally
{
testingId = null;
}
}
private async Task DeleteProvider(Guid id)
{
errorMessage = null;
try
{
await ProviderSvc.DeleteProviderAsync(id);
successMessage = "Provider deleted.";
await LoadProviders();
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
}
}

View File

@@ -0,0 +1,213 @@
@page "/admin/secrets"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
@inject DockerSecretsService SecretsSvc
@inject NavigationManager Navigation
<PageTitle>Secrets - Admin - OTS Signs Orchestrator</PageTitle>
<h3>Docker Secrets</h3>
<p class="text-muted">Manage Docker Swarm secrets. Values are never displayed. You can rotate (delete + recreate) or remove secrets.</p>
@if (errorMessage != null)
{
<div class="alert alert-danger alert-dismissible">
@errorMessage
<button type="button" class="btn-close" @onclick="() => errorMessage = null"></button>
</div>
}
@if (successMessage != null)
{
<div class="alert alert-success alert-dismissible">
@successMessage
<button type="button" class="btn-close" @onclick="() => successMessage = null"></button>
</div>
}
@if (secrets == null)
{
<p>Loading...</p>
}
else if (secrets.Count == 0)
{
<div class="alert alert-info">No secrets found in Docker Swarm.</div>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var s in secrets)
{
<tr>
<td><code>@s.Name</code></td>
<td>@s.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
<td>
<button class="btn btn-sm btn-outline-warning me-1"
@onclick="() => ShowRotateModal(s)"
disabled="@busy">
Rotate
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => ShowDeleteModal(s)"
disabled="@busy">
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
}
@* Rotate Modal *@
@if (rotateTarget != null)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rotate Secret: @rotateTarget.Name</h5>
<button type="button" class="btn-close" @onclick="() => rotateTarget = null"></button>
</div>
<div class="modal-body">
<p>Enter the new value for this secret. The existing secret will be deleted and recreated.</p>
<div class="alert alert-warning">
<strong>Warning:</strong> Services referencing this secret must be redeployed after rotation.
</div>
<div class="mb-3">
<label class="form-label">New Secret Value</label>
<input @bind="newSecretValue" class="form-control" type="password" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="() => rotateTarget = null">Cancel</button>
<button class="btn btn-warning" @onclick="RotateSecret" disabled="@busy">
@(busy ? "Rotating..." : "Rotate")
</button>
</div>
</div>
</div>
</div>
}
@* Delete Modal *@
@if (deleteTarget != null)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Secret: @deleteTarget.Name</h5>
<button type="button" class="btn-close" @onclick="() => deleteTarget = null"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
This will permanently remove the secret from Docker Swarm. Any stacks referencing it will fail.
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="() => deleteTarget = null">Cancel</button>
<button class="btn btn-danger" @onclick="DeleteSecret" disabled="@busy">
@(busy ? "Deleting..." : "Delete")
</button>
</div>
</div>
</div>
</div>
}
@code {
private List<SecretListItem>? secrets;
private string? errorMessage;
private string? successMessage;
private bool busy;
private SecretListItem? rotateTarget;
private SecretListItem? deleteTarget;
private string newSecretValue = "";
protected override async Task OnInitializedAsync()
{
await LoadSecrets();
}
private async Task LoadSecrets()
{
try
{
secrets = await SecretsSvc.ListSecretsAsync();
}
catch (Exception ex)
{
secrets = new();
errorMessage = $"Could not connect to Docker: {ex.InnerException?.Message ?? ex.Message}";
}
}
private void ShowRotateModal(SecretListItem s)
{
rotateTarget = s;
newSecretValue = "";
}
private void ShowDeleteModal(SecretListItem s)
{
deleteTarget = s;
}
private async Task RotateSecret()
{
if (string.IsNullOrWhiteSpace(newSecretValue))
{
errorMessage = "New secret value is required.";
return;
}
busy = true;
errorMessage = null;
try
{
await SecretsSvc.EnsureSecretAsync(rotateTarget!.Name, newSecretValue, rotate: true);
successMessage = $"Secret '{rotateTarget.Name}' rotated successfully.";
rotateTarget = null;
await LoadSecrets();
}
catch (Exception ex)
{
errorMessage = $"Rotation failed: {ex.Message}";
}
finally
{
busy = false;
}
}
private async Task DeleteSecret()
{
busy = true;
errorMessage = null;
try
{
await SecretsSvc.DeleteSecretAsync(deleteTarget!.Name);
successMessage = $"Secret '{deleteTarget.Name}' deleted.";
deleteTarget = null;
await LoadSecrets();
}
catch (Exception ex)
{
errorMessage = $"Delete failed: {ex.Message}";
}
finally
{
busy = false;
}
}
}

View File

@@ -0,0 +1,215 @@
@page "/instances/create"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
@inject InstanceService InstanceSvc
@inject XiboApiService XiboApi
@inject NavigationManager Navigation
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
<h3>Create CMS Instance</h3>
<div class="row">
<div class="col-lg-8">
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
<DataAnnotationsValidator />
<ValidationSummary class="text-danger" />
<fieldset disabled="@deploying">
@* Customer Details *@
<div class="card mb-3">
<div class="card-header">Customer Details</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Customer Name</label>
<InputText @bind-Value="model.CustomerName" class="form-control" placeholder="acme-corp" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Stack Name</label>
<InputText @bind-Value="model.StackName" class="form-control" placeholder="acme-xibo" />
</div>
</div>
</div>
</div>
@* CMS Configuration *@
<div class="card mb-3">
<div class="card-header">CMS Configuration</div>
<div class="card-body">
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label">CMS Server Name</label>
<InputText @bind-Value="model.CmsServerName" class="form-control" placeholder="cms.example.com" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Host HTTP Port</label>
<InputNumber @bind-Value="model.HostHttpPort" class="form-control" />
</div>
</div>
</div>
</div>
@* Storage Paths *@
<div class="card mb-3">
<div class="card-header">Storage Paths (Host Bind Mounts)</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Theme Host Path</label>
<InputText @bind-Value="model.ThemeHostPath" class="form-control" placeholder="/data/xibo-theme" />
</div>
<div class="mb-3">
<label class="form-label">Library Host Path</label>
<InputText @bind-Value="model.LibraryHostPath" class="form-control" placeholder="/data/xibo-library" />
</div>
</div>
</div>
@* SMTP *@
<div class="card mb-3">
<div class="card-header">SMTP Settings</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">SMTP Server</label>
<InputText @bind-Value="model.SmtpServer" class="form-control" placeholder="smtp.example.com" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">SMTP Username</label>
<InputText @bind-Value="model.SmtpUsername" class="form-control" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">SMTP Password</label>
<InputText @bind-Value="model.SmtpPassword" class="form-control" type="password" />
</div>
</div>
</div>
</div>
@* Git Template *@
<div class="card mb-3">
<div class="card-header">Git Template Source</div>
<div class="card-body">
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label">Template Repo URL</label>
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control" placeholder="https://github.com/org/xibo-templates.git" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">PAT / Token (optional)</label>
<InputText @bind-Value="model.TemplateRepoPat" class="form-control" type="password" placeholder="Leave empty for public repos" />
</div>
</div>
</div>
</div>
@* Xibo Credentials *@
<div class="card mb-3">
<div class="card-header">Xibo API Credentials (optional)</div>
<div class="card-body">
<p class="text-muted">Provide credentials to enable API connectivity testing. You can add these later.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Xibo Username (Client ID)</label>
<InputText @bind-Value="model.XiboUsername" class="form-control" placeholder="Optional" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Xibo Password (Client Secret)</label>
<InputText @bind-Value="model.XiboPassword" class="form-control" type="password" placeholder="Optional" />
</div>
</div>
@if (xiboTestResult != null)
{
<div class="alert @(xiboTestResult.IsValid ? "alert-success" : "alert-danger")">
@xiboTestResult.Message
</div>
}
</div>
</div>
@* Constraints *@
<div class="card mb-3">
<div class="card-header">Placement Constraints (optional)</div>
<div class="card-body">
<InputText @bind-Value="constraintsText" class="form-control"
placeholder="node.labels.xibo==true, node.role==manager" />
<small class="text-muted">Comma-separated placement constraints</small>
</div>
</div>
@* Actions *@
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success" disabled="@deploying">
@(deploying ? "Deploying..." : "Deploy Instance")
</button>
<a href="/" class="btn btn-secondary">Cancel</a>
</div>
</fieldset>
</EditForm>
@if (!string.IsNullOrEmpty(resultMessage))
{
<div class="alert @(resultSuccess ? "alert-success" : "alert-danger") mt-3">
@resultMessage
@if (resultSuccess && createdInstanceId.HasValue)
{
<a href="instances/@createdInstanceId" class="alert-link ms-2">View Details</a>
}
</div>
}
</div>
</div>
@code {
private CreateInstanceDto model = new()
{
HostHttpPort = 8080
};
private string? constraintsText;
private bool deploying;
private string? resultMessage;
private bool resultSuccess;
private Guid? createdInstanceId;
private XiboTestResult? xiboTestResult = null!;
private async Task HandleSubmit()
{
deploying = true;
resultMessage = null;
try
{
// Parse constraints
if (!string.IsNullOrWhiteSpace(constraintsText))
{
model.Constraints = constraintsText
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(c => c.Trim())
.Where(c => !string.IsNullOrEmpty(c))
.ToList();
}
var result = await InstanceSvc.CreateInstanceAsync(model);
resultSuccess = result.Success;
resultMessage = result.Success
? $"Instance '{result.StackName}' deployed successfully in {result.DurationMs}ms ({result.ServiceCount} services)."
: $"Deployment failed: {result.ErrorMessage}";
if (result.Success)
{
var instance = (await InstanceSvc.ListInstancesAsync(1, 1, model.StackName)).Items.FirstOrDefault();
createdInstanceId = instance?.Id;
}
}
catch (Exception ex)
{
resultSuccess = false;
resultMessage = $"Error: {ex.Message}";
}
finally
{
deploying = false;
}
}
}

View File

@@ -0,0 +1,145 @@
@page "/instances/{Id:guid}/edit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
@inject InstanceService InstanceSvc
@inject NavigationManager Navigation
<PageTitle>Edit Instance - OTS Signs Orchestrator</PageTitle>
@if (instance == null)
{
<p>Loading...</p>
}
else
{
<h3>Edit: @instance.StackName</h3>
<p class="text-muted">Customer: @instance.CustomerName &mdash; Stack and customer name cannot be changed.</p>
@if (errorMessage != null)
{
<div class="alert alert-danger">@errorMessage</div>
}
@if (successMessage != null)
{
<div class="alert alert-success">@successMessage</div>
}
<EditForm Model="dto" OnValidSubmit="HandleSubmit" FormName="EditInstance">
<DataAnnotationsValidator />
<ValidationSummary />
<h5 class="mt-3">Template Repository</h5>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Template Repo URL</label>
<InputText @bind-Value="dto.TemplateRepoUrl" class="form-control" />
<small class="text-muted">Current: @instance.TemplateRepoUrl</small>
</div>
<div class="col-md-6">
<label class="form-label">PAT (leave blank to keep existing)</label>
<InputText @bind-Value="dto.TemplateRepoPat" class="form-control" type="password" />
</div>
</div>
<h5>SMTP</h5>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">SMTP Server</label>
<InputText @bind-Value="dto.SmtpServer" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">SMTP Username</label>
<InputText @bind-Value="dto.SmtpUsername" class="form-control" />
</div>
</div>
<h5>Placement Constraints</h5>
<div class="mb-3">
<label class="form-label">Constraints (comma-separated)</label>
<InputText @bind-Value="constraintsText" class="form-control" placeholder="e.g., node.labels.customer==acme" />
<small class="text-muted">Current: @(instance.Constraints ?? "default")</small>
</div>
<h5>Xibo API Credentials</h5>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">Xibo Username</label>
<InputText @bind-Value="dto.XiboUsername" class="form-control" />
</div>
<div class="col-md-6">
<label class="form-label">Xibo Password (leave blank to keep existing)</label>
<InputText @bind-Value="dto.XiboPassword" class="form-control" type="password" />
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary" disabled="@saving">
@(saving ? "Updating..." : "Update Instance")
</button>
<a href="instances/@Id" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</EditForm>
}
@code {
[Parameter]
public Guid Id { get; set; }
private CmsInstance? instance;
private UpdateInstanceDto dto = new();
private string? constraintsText;
private bool saving;
private string? errorMessage;
private string? successMessage;
protected override async Task OnInitializedAsync()
{
instance = await InstanceSvc.GetInstanceAsync(Id);
if (instance == null)
{
Navigation.NavigateTo("/");
return;
}
// Pre-fill mutable fields
dto.TemplateRepoUrl = instance.TemplateRepoUrl;
dto.SmtpServer = instance.SmtpServer;
dto.SmtpUsername = instance.SmtpUsername;
dto.XiboUsername = instance.XiboUsername;
constraintsText = instance.Constraints;
}
private async Task HandleSubmit()
{
saving = true;
errorMessage = null;
successMessage = null;
try
{
if (!string.IsNullOrWhiteSpace(constraintsText))
dto.Constraints = constraintsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
else
dto.Constraints = null;
var result = await InstanceSvc.UpdateInstanceAsync(Id, dto);
if (result.Success)
{
successMessage = result.Message;
instance = await InstanceSvc.GetInstanceAsync(Id);
}
else
{
errorMessage = result.Message;
}
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
saving = false;
}
}
}

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,199 @@
@page "/"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject InstanceService InstanceSvc
@inject DockerCliService DockerCli
<PageTitle>Dashboard - OTS Signs Orchestrator</PageTitle>
<h2>CMS Instances</h2>
<div class="row mb-3">
<div class="col-md-4">
<input type="text" class="form-control" placeholder="Filter by name..."
@bind="filterText" @bind:event="oninput" @bind:after="LoadInstances" />
</div>
<div class="col-md-8 text-end">
<AuthorizeView Roles="Admin">
<a href="instances/create" class="btn btn-primary">+ New Instance</a>
</AuthorizeView>
</div>
</div>
@if (loading)
{
<p>Loading...</p>
}
else if (instances == null || instances.Count == 0)
{
<div class="alert alert-info">No instances found. Create your first CMS instance to get started.</div>
}
else
{
<div class="row mb-3">
<div class="col">
<span class="badge bg-success me-2">Active: @instances.Count(i => i.Status == InstanceStatus.Active)</span>
<span class="badge bg-primary me-2">Deploying: @instances.Count(i => i.Status == InstanceStatus.Deploying)</span>
<span class="badge bg-danger me-2">Error: @instances.Count(i => i.Status == InstanceStatus.Error)</span>
<span class="text-muted ms-2">Total: @totalCount</span>
</div>
</div>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Customer</th>
<th>Stack</th>
<th>Server</th>
<th>Port</th>
<th>Status</th>
<th>Xibo API</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var inst in instances)
{
<tr>
<td>@inst.CustomerName</td>
<td><code>@inst.StackName</code></td>
<td>@inst.CmsServerName</td>
<td>@inst.HostHttpPort</td>
<td>
<span class="badge @GetStatusClass(inst.Status)">@inst.Status</span>
</td>
<td>
<span class="badge @GetXiboStatusClass(inst.XiboApiTestStatus)">@inst.XiboApiTestStatus</span>
</td>
<td>@inst.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
<td>
<a href="instances/@inst.Id" class="btn btn-sm btn-outline-primary">View</a>
<AuthorizeView Roles="Admin">
<a href="instances/@inst.Id/edit" class="btn btn-sm btn-outline-secondary">Edit</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(inst)">Delete</button>
</AuthorizeView>
</td>
</tr>
}
</tbody>
</table>
}
@if (showDeleteModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" @onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body">
<p>Delete stack <strong>@deleteTarget?.StackName</strong>?</p>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" @bind="retainSecrets" id="retainSecrets">
<label class="form-check-label" for="retainSecrets">Retain Docker secrets</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="clearXiboCreds" id="clearCreds">
<label class="form-check-label" for="clearCreds">Clear stored Xibo credentials</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @onclick="ExecuteDelete" disabled="@deleting">
@(deleting ? "Deleting..." : "Delete")
</button>
</div>
</div>
</div>
</div>
}
@if (!string.IsNullOrEmpty(statusMessage))
{
<div class="alert @(statusSuccess ? "alert-success" : "alert-danger") mt-3">
@statusMessage
</div>
}
@code {
private List<CmsInstance>? instances;
private int totalCount;
private bool loading = true;
private string? filterText;
private bool showDeleteModal;
private CmsInstance? deleteTarget;
private bool retainSecrets;
private bool clearXiboCreds = true;
private bool deleting;
private string? statusMessage;
private bool statusSuccess;
protected override async Task OnInitializedAsync()
{
await LoadInstances();
}
private async Task LoadInstances()
{
loading = true;
var (items, total) = await InstanceSvc.ListInstancesAsync(1, 100, filterText);
instances = items;
totalCount = total;
loading = false;
}
private void ConfirmDelete(CmsInstance inst)
{
deleteTarget = inst;
retainSecrets = false;
clearXiboCreds = true;
showDeleteModal = true;
}
private async Task ExecuteDelete()
{
if (deleteTarget == null) return;
deleting = true;
try
{
var result = await InstanceSvc.DeleteInstanceAsync(deleteTarget.Id, retainSecrets, clearXiboCreds);
statusMessage = result.Success
? $"Instance '{deleteTarget.StackName}' deleted."
: $"Delete failed: {result.ErrorMessage}";
statusSuccess = result.Success;
showDeleteModal = false;
await LoadInstances();
}
catch (Exception ex)
{
statusMessage = $"Error: {ex.Message}";
statusSuccess = false;
}
finally
{
deleting = false;
}
}
private static string GetStatusClass(InstanceStatus status) => status switch
{
InstanceStatus.Active => "bg-success",
InstanceStatus.Deploying => "bg-primary",
InstanceStatus.Error => "bg-danger",
InstanceStatus.Deleted => "bg-secondary",
_ => "bg-secondary"
};
private static string GetXiboStatusClass(XiboApiTestStatus status) => status switch
{
XiboApiTestStatus.Success => "bg-success",
XiboApiTestStatus.Failed => "bg-danger",
XiboApiTestStatus.Unknown => "bg-warning text-dark",
_ => "bg-secondary"
};
}

View File

@@ -0,0 +1,76 @@
@page "/login"
@inject NavigationManager Navigation
@inject IHttpClientFactory HttpClientFactory
<PageTitle>Login - OTS Signs Orchestrator</PageTitle>
<div class="row justify-content-center mt-5">
<div class="col-md-5">
<div class="card shadow">
<div class="card-body p-4">
<h3 class="card-title text-center mb-4">OTS Signs Orchestrator</h3>
<hr />
<h5 class="mb-3">Admin Token Login</h5>
<EditForm Model="loginModel" OnValidSubmit="SubmitToken" FormName="tokenLogin">
<div class="mb-3">
<label for="token" class="form-label">Admin Token</label>
<InputText id="token" @bind-Value="loginModel.Token" class="form-control" type="password"
placeholder="Enter admin token" />
</div>
<button type="submit" class="btn btn-primary w-100" disabled="@submitting">
@(submitting ? "Authenticating..." : "Sign In")
</button>
</EditForm>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger mt-3">@errorMessage</div>
}
</div>
</div>
</div>
</div>
@code {
private TokenLoginModel loginModel = new();
private bool submitting;
private string? errorMessage;
private async Task SubmitToken()
{
submitting = true;
errorMessage = null;
try
{
var client = HttpClientFactory.CreateClient();
client.BaseAddress = new Uri(Navigation.BaseUri);
var response = await client.PostAsJsonAsync("api/auth/verify-token", new { token = loginModel.Token });
if (response.IsSuccessStatusCode)
{
Navigation.NavigateTo("/", forceLoad: true);
}
else
{
errorMessage = "Invalid token. Please try again.";
}
}
catch (Exception ex)
{
errorMessage = $"Login failed: {ex.Message}";
}
finally
{
submitting = false;
}
}
private class TokenLoginModel
{
public string Token { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,213 @@
@page "/instances/{Id:guid}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject InstanceService InstanceSvc
@inject DockerCliService DockerCli
@inject NavigationManager Navigation
<PageTitle>Instance Details - OTS Signs Orchestrator</PageTitle>
@if (instance == null)
{
<p>Loading...</p>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h3>@instance.StackName</h3>
<span class="text-muted">Customer: @instance.CustomerName</span>
</div>
<div>
<span class="badge @GetStatusClass(instance.Status) fs-6">@instance.Status</span>
</div>
</div>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link @(activeTab == "info" ? "active" : "")" @onclick='() => activeTab = "info"'>Info</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "xibo" ? "active" : "")" @onclick='() => activeTab = "xibo"'>Xibo Status</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "services" ? "active" : "")" @onclick='() => LoadServices()'>Services</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "compose" ? "active" : "")" @onclick='() => activeTab = "compose"'>Compose</button>
</li>
</ul>
@* Info Tab *@
@if (activeTab == "info")
{
<div class="row">
<div class="col-md-6">
<table class="table">
<tr><th>CMS Server Name</th><td>@instance.CmsServerName</td></tr>
<tr><th>HTTP Port</th><td>@instance.HostHttpPort</td></tr>
<tr><th>Theme Path</th><td><code>@instance.ThemeHostPath</code></td></tr>
<tr><th>Library Path</th><td><code>@instance.LibraryHostPath</code></td></tr>
<tr><th>SMTP Server</th><td>@instance.SmtpServer</td></tr>
<tr><th>SMTP User</th><td>@instance.SmtpUsername</td></tr>
<tr><th>Template Repo</th><td><small>@instance.TemplateRepoUrl</small></td></tr>
<tr><th>Last Fetch</th><td>@instance.TemplateLastFetch?.ToString("yyyy-MM-dd HH:mm")</td></tr>
<tr><th>Constraints</th><td><code>@(instance.Constraints ?? "default")</code></td></tr>
<tr><th>Created</th><td>@instance.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td></tr>
<tr><th>Updated</th><td>@instance.UpdatedAt.ToString("yyyy-MM-dd HH:mm")</td></tr>
</table>
</div>
</div>
}
@* Xibo Status Tab *@
@if (activeTab == "xibo")
{
<div class="card">
<div class="card-body">
<h5>Xibo API Connection</h5>
<p>
Status:
<span class="badge @GetXiboStatusClass(instance.XiboApiTestStatus)">@instance.XiboApiTestStatus</span>
@if (instance.XiboApiTestedAt.HasValue)
{
<small class="text-muted ms-2">tested @instance.XiboApiTestedAt.Value.ToString("yyyy-MM-dd HH:mm")</small>
}
</p>
<p>Username: <code>@(instance.XiboUsername ?? "Not set")</code></p>
<AuthorizeView Roles="Admin">
<button class="btn btn-outline-primary" @onclick="TestXiboConnection" disabled="@testingXibo">
@(testingXibo ? "Testing..." : "Re-test Connection")
</button>
</AuthorizeView>
@if (xiboTestResult != null)
{
<div class="alert @(xiboTestResult.IsValid ? "alert-success" : "alert-danger") mt-2">
@xiboTestResult.Message
</div>
}
<hr />
<p class="text-muted">Future: Layouts, Displays, Scheduling management (coming soon)</p>
</div>
</div>
}
@* Services Tab *@
@if (activeTab == "services")
{
@if (services == null)
{
<p>Loading services...</p>
}
else if (services.Count == 0)
{
<div class="alert alert-warning">No services found for this stack.</div>
}
else
{
<table class="table">
<thead><tr><th>Service</th><th>Image</th><th>Replicas</th></tr></thead>
<tbody>
@foreach (var svc in services)
{
<tr>
<td>@svc.Name</td>
<td><code>@svc.Image</code></td>
<td>@svc.Replicas</td>
</tr>
}
</tbody>
</table>
}
}
@* Compose Tab *@
@if (activeTab == "compose")
{
<div class="card">
<div class="card-body">
<p class="text-muted">Rendered Compose YAML (read-only). Re-generate by editing and updating the instance.</p>
<pre class="bg-dark text-light p-3 rounded" style="max-height: 600px; overflow-y: auto;">
<code>@composeYaml</code>
</pre>
</div>
</div>
}
@* Actions *@
<div class="mt-4">
<AuthorizeView Roles="Admin">
<a href="instances/@instance.Id/edit" class="btn btn-primary me-2">Edit Instance</a>
</AuthorizeView>
<a href="/" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
}
@code {
[Parameter]
public Guid Id { get; set; }
private CmsInstance? instance;
private string activeTab = "info";
private List<ServiceInfo>? services;
private string? composeYaml = "Compose YAML will be regenerated when you update the instance.";
private bool testingXibo;
private XiboTestResult? xiboTestResult;
protected override async Task OnInitializedAsync()
{
instance = await InstanceSvc.GetInstanceAsync(Id);
if (instance == null)
Navigation.NavigateTo("/");
}
private async Task LoadServices()
{
activeTab = "services";
if (instance != null)
{
services = await DockerCli.InspectStackServicesAsync(instance.StackName);
}
}
private async Task TestXiboConnection()
{
testingXibo = true;
xiboTestResult = null;
try
{
xiboTestResult = await InstanceSvc.TestXiboConnectionAsync(Id);
// Reload instance to get updated test status
instance = await InstanceSvc.GetInstanceAsync(Id);
}
catch (Exception ex)
{
xiboTestResult = new XiboTestResult { IsValid = false, Message = ex.Message };
}
finally
{
testingXibo = false;
}
}
private static string GetStatusClass(InstanceStatus status) => status switch
{
InstanceStatus.Active => "bg-success",
InstanceStatus.Deploying => "bg-primary",
InstanceStatus.Error => "bg-danger",
InstanceStatus.Deleted => "bg-secondary",
_ => "bg-secondary"
};
private static string GetXiboStatusClass(XiboApiTestStatus status) => status switch
{
XiboApiTestStatus.Success => "bg-success",
XiboApiTestStatus.Failed => "bg-danger",
XiboApiTestStatus.Unknown => "bg-warning text-dark",
_ => "bg-secondary"
};
}

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -0,0 +1,15 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.JSInterop
@using OTSSignsOrchestrator
@using OTSSignsOrchestrator.Components
@using OTSSignsOrchestrator.Models.Entities
@using OTSSignsOrchestrator.Models.DTOs
@using OTSSignsOrchestrator.Services
@using OTSSignsOrchestrator.Configuration

View File

@@ -0,0 +1,25 @@
namespace OTSSignsOrchestrator.Configuration;
/// <summary>
/// Shared constants for the application.
/// </summary>
public static class AppConstants
{
public const string AdminRole = "Admin";
public const string ViewerRole = "Viewer";
public const string AdminTokenScheme = "AdminToken";
public const string OidcScheme = "OpenIdConnect";
public const string CookieScheme = "Cookies";
/// <summary>Docker secret name for the global SMTP password.</summary>
public const string GlobalSmtpSecretName = "global_smtp_password";
/// <summary>Build a per-customer MySQL password secret name.</summary>
public static string CustomerMysqlSecretName(string customerName)
=> $"{SanitizeName(customerName)}_mysql_password";
/// <summary>Sanitize a customer name for use in Docker/secret names.</summary>
public static string SanitizeName(string name)
=> new string(name.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray()).ToLowerInvariant();
}

View File

@@ -0,0 +1,55 @@
namespace OTSSignsOrchestrator.Configuration;
public class FileLoggingOptions
{
public const string SectionName = "FileLogging";
public bool Enabled { get; set; } = true;
public string Path { get; set; } = "/var/log/xibo-admin";
public string RollingInterval { get; set; } = "Day";
public int RetentionDays { get; set; } = 30;
public long FileSizeLimitBytes { get; set; } = 100 * 1024 * 1024; // 100MB
}
public class AuthenticationOptions
{
public const string SectionName = "Authentication";
public string LocalAdminToken { get; set; } = string.Empty;
}
public class GitOptions
{
public const string SectionName = "Git";
public string CacheDir { get; set; } = "/var/cache/xibo-admin-templates";
public int CacheTtlMinutes { get; set; } = 60;
public int ShallowCloneDepth { get; set; } = 1;
}
public class DockerOptions
{
public const string SectionName = "Docker";
public string SocketPath { get; set; } = "unix:///var/run/docker.sock";
public List<string> DefaultConstraints { get; set; } = new() { "node.labels.xibo==true" };
public int DeployTimeoutSeconds { get; set; } = 30;
public bool ValidateBeforeDeploy { get; set; } = true;
}
public class XiboDefaultImages
{
public string Cms { get; set; } = "ghcr.io/xibosignage/xibo-cms:release-4.4.0";
public string Mysql { get; set; } = "mysql:8.4";
public string Memcached { get; set; } = "memcached:alpine";
public string QuickChart { get; set; } = "ianw/quickchart";
}
public class XiboOptions
{
public const string SectionName = "Xibo";
public XiboDefaultImages DefaultImages { get; set; } = new();
public int TestConnectionTimeoutSeconds { get; set; } = 10;
}
public class DatabaseOptions
{
public const string SectionName = "Database";
public string Provider { get; set; } = "Sqlite"; // Sqlite or PostgreSQL
}

View File

@@ -0,0 +1,169 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Serilog;
using Serilog.Events;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Configuration;
public static class DependencyInjection
{
public static WebApplicationBuilder AddXiboSwarmServices(this WebApplicationBuilder builder)
{
var config = builder.Configuration;
// --- Options ---
builder.Services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
builder.Services.Configure<AuthenticationOptions>(config.GetSection(AuthenticationOptions.SectionName));
builder.Services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
builder.Services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
builder.Services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
builder.Services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
// --- Serilog ---
ConfigureSerilog(builder);
// --- Data Protection (encrypts secrets at rest) ---
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(
Path.Combine(builder.Environment.ContentRootPath, "keys")))
.SetApplicationName("OTSSignsOrchestrator");
// --- Database ---
var dbProvider = config.GetValue<string>("Database:Provider") ?? "Sqlite";
var connStr = config.GetConnectionString("Default") ?? "Data Source=xibo-admin.db";
builder.Services.AddDbContext<XiboContext>(options =>
{
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
options.UseNpgsql(connStr);
else
options.UseSqlite(connStr);
});
// --- Authentication ---
ConfigureAuthentication(builder);
// --- HTTP Clients ---
builder.Services.AddHttpClient("XiboApi")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
// Accept self-signed certs in dev
ServerCertificateCustomValidationCallback =
builder.Environment.IsDevelopment()
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
: null!
});
builder.Services.AddHttpClient(); // Default factory
// --- Application Services ---
builder.Services.AddScoped<GitTemplateService>();
builder.Services.AddScoped<ComposeRenderService>();
builder.Services.AddScoped<ComposeValidationService>();
builder.Services.AddScoped<DockerCliService>();
builder.Services.AddScoped<DockerSecretsService>();
builder.Services.AddScoped<XiboApiService>();
builder.Services.AddScoped<InstanceService>();
builder.Services.AddScoped<OidcProviderService>();
// --- API Controllers ---
builder.Services.AddControllers();
return builder;
}
private static void ConfigureSerilog(WebApplicationBuilder builder)
{
var logConfig = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "OTSSignsOrchestrator")
.WriteTo.Console();
var fileLogging = builder.Configuration.GetSection(FileLoggingOptions.SectionName).Get<FileLoggingOptions>();
if (fileLogging?.Enabled == true)
{
var logPath = fileLogging.Path;
if (!Path.IsPathRooted(logPath))
logPath = Path.Combine(builder.Environment.ContentRootPath, logPath);
Directory.CreateDirectory(logPath);
// App log
logConfig.WriteTo.File(
Path.Combine(logPath, "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: fileLogging.RetentionDays,
fileSizeLimitBytes: fileLogging.FileSizeLimitBytes,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}");
}
Log.Logger = logConfig.CreateLogger();
builder.Host.UseSerilog();
}
private static void ConfigureAuthentication(WebApplicationBuilder builder)
{
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.AccessDeniedPath = "/access-denied";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
.AddScheme<AuthenticationSchemeOptions, AdminTokenAuthHandler>(
AppConstants.AdminTokenScheme, _ => { });
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole(AppConstants.AdminRole));
});
builder.Services.AddCascadingAuthenticationState();
}
public static WebApplication UseXiboSwarmMiddleware(this WebApplication app)
{
// Security headers
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
await next();
});
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// Health check
app.MapGet("/healthz", () => Results.Ok(new
{
status = "healthy",
timestamp = DateTime.UtcNow
})).AllowAnonymous();
return app;
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Models.Entities;
namespace OTSSignsOrchestrator.Data;
public class XiboContext : DbContext
{
private readonly IDataProtectionProvider? _dataProtection;
public XiboContext(DbContextOptions<XiboContext> options, IDataProtectionProvider? dataProtection = null)
: base(options)
{
_dataProtection = dataProtection;
}
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
public DbSet<OidcProvider> OidcProviders => Set<OidcProvider>();
public DbSet<SecretMetadata> SecretMetadata => Set<SecretMetadata>();
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// --- CmsInstance ---
modelBuilder.Entity<CmsInstance>(entity =>
{
entity.HasIndex(e => e.StackName).IsUnique();
entity.HasIndex(e => e.CustomerName);
// Query filter for soft deletes
entity.HasQueryFilter(e => e.DeletedAt == null);
// Encrypt sensitive fields if DataProtection is available
if (_dataProtection != null)
{
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.CmsInstance");
var pwdConverter = new ValueConverter<string?, string?>(
v => v != null ? protector.Protect(v) : null,
v => v != null ? protector.Unprotect(v) : null);
entity.Property(e => e.XiboPassword).HasConversion(pwdConverter);
entity.Property(e => e.XiboUsername).HasConversion(pwdConverter);
entity.Property(e => e.TemplateRepoPat).HasConversion(pwdConverter);
}
});
// --- OidcProvider ---
modelBuilder.Entity<OidcProvider>(entity =>
{
entity.HasIndex(e => e.Name).IsUnique();
if (_dataProtection != null)
{
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.OidcProvider");
var secretConverter = new ValueConverter<string, string>(
v => protector.Protect(v),
v => protector.Unprotect(v));
entity.Property(e => e.ClientSecret).HasConversion(secretConverter);
}
});
// --- SecretMetadata ---
modelBuilder.Entity<SecretMetadata>(entity =>
{
entity.HasIndex(e => e.Name).IsUnique();
});
// --- OperationLog ---
modelBuilder.Entity<OperationLog>(entity =>
{
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.InstanceId);
entity.HasIndex(e => e.Operation);
});
}
}

View File

@@ -0,0 +1,53 @@
# ==============================================================================
# OTSSignsOrchestrator - Multi-stage Dockerfile
# ==============================================================================
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy csproj and restore
COPY OTSSignsOrchestrator.csproj ./
RUN dotnet restore
# Copy everything else and publish
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# ==============================================================================
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
# Install Docker CLI for stack deploy/rm/ls commands
RUN apt-get update && \
apt-get install -y --no-install-recommends \
docker.io \
git \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Create non-root user (will need docker group for socket access)
RUN groupadd -r xiboapp && \
useradd -r -g xiboapp -d /app -s /sbin/nologin xiboapp
# Copy published app
COPY --from=build /app/publish .
# Create directories for logs, data, and template cache
RUN mkdir -p /app/logs /app/data /app/template-cache && \
chown -R xiboapp:xiboapp /app
# Expose port (Kestrel default in .NET 9)
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/healthz || exit 1
# Switch to non-root user
USER xiboapp
# Environment defaults
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "OTSSignsOrchestrator.dll"]

View File

@@ -0,0 +1,276 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Data;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260212185423_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OidcProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Audience")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Authority")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ClientId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ClientSecret")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("OidcProviders");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<Guid?>("ProviderId")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("ProviderId");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
{
b.HasOne("XiboSwarmAdmin.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.HasOne("XiboSwarmAdmin.Models.Entities.OidcProvider", "Provider")
.WithMany()
.HasForeignKey("ProviderId");
b.Navigation("Instance");
b.Navigation("Provider");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,172 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CmsInstances",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
StackName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
CmsServerName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
HostHttpPort = table.Column<int>(type: "INTEGER", nullable: false),
ThemeHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
LibraryHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
SmtpServer = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
SmtpUsername = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Constraints = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
TemplateRepoUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
TemplateRepoPat = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
TemplateLastFetch = table.Column<DateTime>(type: "TEXT", nullable: true),
TemplateCacheKey = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
XiboUsername = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
XiboPassword = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
XiboApiTestStatus = table.Column<int>(type: "INTEGER", nullable: false),
XiboApiTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
DeletedAt = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CmsInstances", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OidcProviders",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Authority = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ClientId = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ClientSecret = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
Audience = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
IsPrimary = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OidcProviders", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SecretMetadata",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
IsGlobal = table.Column<bool>(type: "INTEGER", nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
LastRotatedAt = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SecretMetadata", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OperationLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Operation = table.Column<int>(type: "INTEGER", nullable: false),
InstanceId = table.Column<Guid>(type: "TEXT", nullable: true),
ProviderId = table.Column<Guid>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
Message = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
DurationMs = table.Column<long>(type: "INTEGER", nullable: true),
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
IpAddress = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OperationLogs", x => x.Id);
table.ForeignKey(
name: "FK_OperationLogs_CmsInstances_InstanceId",
column: x => x.InstanceId,
principalTable: "CmsInstances",
principalColumn: "Id");
table.ForeignKey(
name: "FK_OperationLogs_OidcProviders_ProviderId",
column: x => x.ProviderId,
principalTable: "OidcProviders",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_CustomerName",
table: "CmsInstances",
column: "CustomerName");
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_StackName",
table: "CmsInstances",
column: "StackName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OidcProviders_Name",
table: "OidcProviders",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_InstanceId",
table: "OperationLogs",
column: "InstanceId");
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_Operation",
table: "OperationLogs",
column: "Operation");
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_ProviderId",
table: "OperationLogs",
column: "ProviderId");
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_Timestamp",
table: "OperationLogs",
column: "Timestamp");
migrationBuilder.CreateIndex(
name: "IX_SecretMetadata_Name",
table: "SecretMetadata",
column: "Name",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OperationLogs");
migrationBuilder.DropTable(
name: "SecretMetadata");
migrationBuilder.DropTable(
name: "CmsInstances");
migrationBuilder.DropTable(
name: "OidcProviders");
}
}
}

View File

@@ -0,0 +1,273 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Data;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
[DbContext(typeof(XiboContext))]
partial class XiboContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OidcProvider", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Audience")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Authority")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ClientId")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ClientSecret")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("OidcProviders");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<Guid?>("ProviderId")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("ProviderId");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
{
b.HasOne("XiboSwarmAdmin.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.HasOne("XiboSwarmAdmin.Models.Entities.OidcProvider", "Provider")
.WithMany()
.HasForeignKey("ProviderId");
b.Navigation("Instance");
b.Navigation("Provider");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.DTOs;
public class CreateInstanceDto
{
[Required, MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string StackName { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string CmsServerName { get; set; } = string.Empty;
[Required, Range(1024, 65535)]
public int HostHttpPort { get; set; }
[Required, MaxLength(500)]
public string ThemeHostPath { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string LibraryHostPath { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpServer { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpUsername { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpPassword { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string TemplateRepoUrl { get; set; } = string.Empty;
[MaxLength(500)]
public string? TemplateRepoPat { get; set; }
/// <summary>Comma-separated placement constraints.</summary>
public List<string>? Constraints { get; set; }
[MaxLength(200)]
public string? XiboUsername { get; set; }
[MaxLength(200)]
public string? XiboPassword { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace OTSSignsOrchestrator.Models.DTOs;
public class DeploymentResultDto
{
public bool Success { get; set; }
public string StackName { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? Output { get; set; }
public string? ErrorMessage { get; set; }
public int ExitCode { get; set; }
public long DurationMs { get; set; }
public int ServiceCount { get; set; }
}

View File

@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.DTOs;
public class CreateOidcProviderDto
{
[Required, MaxLength(100)]
public string Name { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string Authority { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string ClientId { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string ClientSecret { get; set; } = string.Empty;
[MaxLength(200)]
public string? Audience { get; set; }
public bool IsEnabled { get; set; } = true;
public bool IsPrimary { get; set; }
}
public class UpdateOidcProviderDto
{
[MaxLength(100)]
public string? Name { get; set; }
[MaxLength(500)]
public string? Authority { get; set; }
[MaxLength(500)]
public string? ClientId { get; set; }
[MaxLength(500)]
public string? ClientSecret { get; set; }
[MaxLength(200)]
public string? Audience { get; set; }
public bool? IsEnabled { get; set; }
public bool? IsPrimary { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace OTSSignsOrchestrator.Models.DTOs;
public class TemplateConfig
{
public string Yaml { get; set; } = string.Empty;
public List<string> EnvLines { get; set; } = new();
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.DTOs;
public class UpdateInstanceDto
{
[MaxLength(500)]
public string? TemplateRepoUrl { get; set; }
[MaxLength(500)]
public string? TemplateRepoPat { get; set; }
[MaxLength(200)]
public string? SmtpServer { get; set; }
[MaxLength(200)]
public string? SmtpUsername { get; set; }
public List<string>? Constraints { get; set; }
[MaxLength(200)]
public string? XiboUsername { get; set; }
[MaxLength(200)]
public string? XiboPassword { get; set; }
}

View File

@@ -0,0 +1,95 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OTSSignsOrchestrator.Models.Entities;
public enum InstanceStatus
{
Deploying,
Active,
Error,
Deleted
}
public enum XiboApiTestStatus
{
Unknown,
Success,
Failed
}
public class CmsInstance
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required, MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string StackName { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string CmsServerName { get; set; } = string.Empty;
[Required, Range(1024, 65535)]
public int HostHttpPort { get; set; }
[Required, MaxLength(500)]
public string ThemeHostPath { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string LibraryHostPath { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpServer { get; set; } = string.Empty;
[Required, MaxLength(200)]
public string SmtpUsername { get; set; } = string.Empty;
/// <summary>
/// JSON array of placement constraints, e.g. ["node.labels.xibo==true"]
/// </summary>
[MaxLength(2000)]
public string? Constraints { get; set; }
[Required, MaxLength(500)]
public string TemplateRepoUrl { get; set; } = string.Empty;
[MaxLength(500)]
public string? TemplateRepoPat { get; set; }
public DateTime? TemplateLastFetch { get; set; }
[MaxLength(100)]
public string? TemplateCacheKey { get; set; }
public InstanceStatus Status { get; set; } = InstanceStatus.Deploying;
/// <summary>
/// Encrypted Xibo admin username for API access.
/// </summary>
[MaxLength(500)]
public string? XiboUsername { get; set; }
/// <summary>
/// Encrypted Xibo admin password for API access.
/// Never logged; encrypted at rest via Data Protection.
/// </summary>
[MaxLength(1000)]
public string? XiboPassword { get; set; }
public XiboApiTestStatus XiboApiTestStatus { get; set; } = XiboApiTestStatus.Unknown;
public DateTime? XiboApiTestedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Soft delete marker.</summary>
public DateTime? DeletedAt { get; set; }
// Navigation properties
public ICollection<OperationLog> OperationLogs { get; set; } = new List<OperationLog>();
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.Entities;
public class OidcProvider
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required, MaxLength(100)]
public string Name { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string Authority { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Encrypted via Data Protection. Never logged.
/// </summary>
[Required, MaxLength(2000)]
public string ClientSecret { get; set; } = string.Empty;
[MaxLength(200)]
public string? Audience { get; set; }
public bool IsEnabled { get; set; } = true;
public bool IsPrimary { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OTSSignsOrchestrator.Models.Entities;
public enum OperationType
{
Create,
Update,
Delete,
TestXibo,
TestIdP,
DeploymentStatus,
Other
}
public enum OperationStatus
{
Pending,
Success,
Failure
}
public class OperationLog
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
public OperationType Operation { get; set; }
public Guid? InstanceId { get; set; }
[ForeignKey(nameof(InstanceId))]
public CmsInstance? Instance { get; set; }
public Guid? ProviderId { get; set; }
[ForeignKey(nameof(ProviderId))]
public OidcProvider? Provider { get; set; }
[MaxLength(200)]
public string? UserId { get; set; }
public OperationStatus Status { get; set; } = OperationStatus.Pending;
/// <summary>
/// Human-readable message. NEVER includes secret values.
/// </summary>
[MaxLength(2000)]
public string? Message { get; set; }
public long? DurationMs { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[MaxLength(50)]
public string? IpAddress { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.Entities;
public class SecretMetadata
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required, MaxLength(200)]
public string Name { get; set; } = string.Empty;
public bool IsGlobal { get; set; }
[MaxLength(100)]
public string? CustomerName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastRotatedAt { get; set; }
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using Serilog;
using OTSSignsOrchestrator.Components;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Data;
var builder = WebApplication.CreateBuilder(args);
// Add OTS Signs Orchestrator services (DB, Auth, Logging, Docker, Git, etc.)
builder.AddXiboSwarmServices();
// Add Blazor components
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
// Apply EF Core migrations on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
await db.Database.MigrateAsync();
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseSerilogRequestLogging();
app.UseHttpsRedirection();
// Apply Xibo Swarm middleware (security headers, auth, healthcheck, controllers)
app.UseXiboSwarmMiddleware();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5230",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7157;http://localhost:5230",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Authenticates requests using a static bearer token (for bootstrap / recovery).
/// </summary>
public class AdminTokenAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly string _expectedToken;
public AdminTokenAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IOptions<Configuration.AuthenticationOptions> authOptions)
: base(options, logger, encoder)
{
_expectedToken = authOptions.Value.LocalAdminToken;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (string.IsNullOrEmpty(_expectedToken))
return Task.FromResult(AuthenticateResult.NoResult());
string? authorization = Request.Headers.Authorization.FirstOrDefault();
if (string.IsNullOrEmpty(authorization))
return Task.FromResult(AuthenticateResult.NoResult());
if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Task.FromResult(AuthenticateResult.NoResult());
var token = authorization["Bearer ".Length..].Trim();
if (!string.Equals(token, _expectedToken, StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.Fail("Invalid admin token."));
var claims = new[]
{
new Claim(ClaimTypes.Name, "LocalAdmin"),
new Claim(ClaimTypes.Role, AppConstants.AdminRole),
new Claim("auth_method", "admin_token")
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,363 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Models.DTOs;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Renders a Compose v3.8 YAML from a template, merged with user inputs,
/// secrets references, bind mounts, and placement constraints.
/// </summary>
public class ComposeRenderService
{
private readonly XiboOptions _xiboOptions;
private readonly DockerOptions _dockerOptions;
private readonly ILogger<ComposeRenderService> _logger;
public ComposeRenderService(
IOptions<XiboOptions> xiboOptions,
IOptions<DockerOptions> dockerOptions,
ILogger<ComposeRenderService> logger)
{
_xiboOptions = xiboOptions.Value;
_dockerOptions = dockerOptions.Value;
_logger = logger;
}
/// <summary>
/// Render a final Compose YAML from the template + user inputs + secrets.
/// </summary>
public string Render(RenderContext ctx)
{
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
// Parse template YAML
var yaml = new YamlStream();
using (var reader = new StringReader(ctx.TemplateYaml))
{
yaml.Load(reader);
}
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
// Ensure version
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.8");
// Process services
EnsureServices(root, ctx);
// Process volumes
EnsureVolumes(root, ctx);
// Process secrets
EnsureSecrets(root, ctx);
// Serialize back to YAML
var serializer = new SerializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build();
using var writer = new StringWriter();
yaml.Save(writer, assignAnchors: false);
var output = writer.ToString();
// Clean up YAML artifacts
output = output.Replace("...\n", "").Replace("...", "");
_logger.LogDebug("Compose rendered: {ServiceCount} services, {SecretCount} secrets",
GetServiceCount(root), ctx.SecretNames.Count);
return output;
}
private void EnsureServices(YamlMappingNode root, RenderContext ctx)
{
if (!root.Children.ContainsKey(new YamlScalarNode("services")))
root.Children[new YamlScalarNode("services")] = new YamlMappingNode();
var services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
// CMS Database (MySQL)
EnsureCmsDb(services, ctx);
// CMS Web (Xibo)
EnsureCmsWeb(services, ctx);
// Memcached
EnsureMemcached(services, ctx);
// QuickChart
EnsureQuickChart(services, ctx);
// Remove XMR if present
var xmrKey = new YamlScalarNode("cms-xmr");
if (services.Children.ContainsKey(xmrKey))
{
services.Children.Remove(xmrKey);
_logger.LogInformation("Removed cms-xmr service from compose (not needed for 4.4.0)");
}
}
private void EnsureCmsDb(YamlMappingNode services, RenderContext ctx)
{
var key = new YamlScalarNode("cms-db");
YamlMappingNode svc;
if (services.Children.ContainsKey(key))
svc = (YamlMappingNode)services.Children[key];
else
{
svc = new YamlMappingNode();
services.Children[key] = svc;
}
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Mysql);
// Environment
var env = new YamlMappingNode
{
{ "MYSQL_DATABASE", "cms" },
{ "MYSQL_USER", "cms" },
{ "MYSQL_PASSWORD_FILE", $"/run/secrets/{AppConstants.CustomerMysqlSecretName(ctx.CustomerName)}" },
{ "MYSQL_RANDOM_ROOT_PASSWORD", "yes" }
};
svc.Children[new YamlScalarNode("environment")] = env;
// Volumes
var volumes = new YamlSequenceNode(
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_db:/var/lib/mysql")
);
svc.Children[new YamlScalarNode("volumes")] = volumes;
// Secrets
var secrets = new YamlSequenceNode(
new YamlScalarNode(AppConstants.CustomerMysqlSecretName(ctx.CustomerName))
);
svc.Children[new YamlScalarNode("secrets")] = secrets;
// Placement constraints
ApplyConstraints(svc, ctx);
}
private void EnsureCmsWeb(YamlMappingNode services, RenderContext ctx)
{
var key = new YamlScalarNode("cms-web");
YamlMappingNode svc;
if (services.Children.ContainsKey(key))
svc = (YamlMappingNode)services.Children[key];
else
{
svc = new YamlMappingNode();
services.Children[key] = svc;
}
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Cms);
// Merge template env with overrides
var env = new YamlMappingNode();
// Apply template.env defaults first
foreach (var line in ctx.TemplateEnvLines)
{
var eqIdx = line.IndexOf('=');
if (eqIdx > 0)
{
var k = line[..eqIdx].Trim();
var v = line[(eqIdx + 1)..].Trim();
env.Children[new YamlScalarNode(k)] = new YamlScalarNode(v);
}
}
// Override with our required values
env.Children[new YamlScalarNode("CMS_SERVER_NAME")] = new YamlScalarNode(ctx.CmsServerName);
env.Children[new YamlScalarNode("MYSQL_HOST")] = new YamlScalarNode("cms-db");
env.Children[new YamlScalarNode("MYSQL_DATABASE")] = new YamlScalarNode("cms");
env.Children[new YamlScalarNode("MYSQL_USER")] = new YamlScalarNode("cms");
env.Children[new YamlScalarNode("MYSQL_PASSWORD_FILE")] =
new YamlScalarNode($"/run/secrets/{AppConstants.CustomerMysqlSecretName(ctx.CustomerName)}");
env.Children[new YamlScalarNode("CMS_SMTP_SERVER")] = new YamlScalarNode(ctx.SmtpServer);
env.Children[new YamlScalarNode("CMS_SMTP_USERNAME")] = new YamlScalarNode(ctx.SmtpUsername);
env.Children[new YamlScalarNode("CMS_SMTP_PASSWORD_FILE")] =
new YamlScalarNode($"/run/secrets/{AppConstants.GlobalSmtpSecretName}");
env.Children[new YamlScalarNode("MEMCACHED_HOST")] = new YamlScalarNode("cms-memcached");
env.Children[new YamlScalarNode("QUICKCHART_API_URL")] = new YamlScalarNode("http://cms-quickchart:3400");
svc.Children[new YamlScalarNode("environment")] = env;
// Ports
var ports = new YamlSequenceNode(
new YamlScalarNode($"{ctx.HostHttpPort}:80")
);
svc.Children[new YamlScalarNode("ports")] = ports;
// Volumes (bind mounts + named volumes)
var volumes = new YamlSequenceNode(
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
new YamlScalarNode($"{ctx.LibraryHostPath}:/var/www/cms/library"),
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_backup:/var/www/cms/backup"),
new YamlScalarNode($"{AppConstants.SanitizeName(ctx.CustomerName)}_cms_custom:/var/www/cms/custom")
);
svc.Children[new YamlScalarNode("volumes")] = volumes;
// Secrets
var secrets = new YamlSequenceNode(
new YamlScalarNode(AppConstants.CustomerMysqlSecretName(ctx.CustomerName)),
new YamlScalarNode(AppConstants.GlobalSmtpSecretName)
);
svc.Children[new YamlScalarNode("secrets")] = secrets;
// Depends on
svc.Children[new YamlScalarNode("depends_on")] = new YamlSequenceNode(
new YamlScalarNode("cms-db"),
new YamlScalarNode("cms-memcached")
);
ApplyConstraints(svc, ctx);
}
private void EnsureMemcached(YamlMappingNode services, RenderContext ctx)
{
var key = new YamlScalarNode("cms-memcached");
YamlMappingNode svc;
if (services.Children.ContainsKey(key))
svc = (YamlMappingNode)services.Children[key];
else
{
svc = new YamlMappingNode();
services.Children[key] = svc;
}
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Memcached);
ApplyConstraints(svc, ctx);
}
private void EnsureQuickChart(YamlMappingNode services, RenderContext ctx)
{
var key = new YamlScalarNode("cms-quickchart");
YamlMappingNode svc;
if (services.Children.ContainsKey(key))
svc = (YamlMappingNode)services.Children[key];
else
{
svc = new YamlMappingNode();
services.Children[key] = svc;
}
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.QuickChart);
ApplyConstraints(svc, ctx);
}
private void ApplyConstraints(YamlMappingNode service, RenderContext ctx)
{
if (ctx.Constraints == null || ctx.Constraints.Count == 0)
return;
var deployKey = new YamlScalarNode("deploy");
YamlMappingNode deploy;
if (service.Children.ContainsKey(deployKey))
deploy = (YamlMappingNode)service.Children[deployKey];
else
{
deploy = new YamlMappingNode();
service.Children[deployKey] = deploy;
}
var placementKey = new YamlScalarNode("placement");
YamlMappingNode placement;
if (deploy.Children.ContainsKey(placementKey))
placement = (YamlMappingNode)deploy.Children[placementKey];
else
{
placement = new YamlMappingNode();
deploy.Children[placementKey] = placement;
}
var constraintNodes = ctx.Constraints
.Select(c => (YamlNode)new YamlScalarNode(c))
.ToList();
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(constraintNodes);
}
private void EnsureVolumes(YamlMappingNode root, RenderContext ctx)
{
var volumesKey = new YamlScalarNode("volumes");
YamlMappingNode volumes;
if (root.Children.ContainsKey(volumesKey))
volumes = (YamlMappingNode)root.Children[volumesKey];
else
{
volumes = new YamlMappingNode();
root.Children[volumesKey] = volumes;
}
var prefix = AppConstants.SanitizeName(ctx.CustomerName);
// Named volumes (db, backup, custom)
volumes.Children[new YamlScalarNode($"{prefix}_cms_db")] = new YamlMappingNode();
volumes.Children[new YamlScalarNode($"{prefix}_cms_backup")] = new YamlMappingNode();
volumes.Children[new YamlScalarNode($"{prefix}_cms_custom")] = new YamlMappingNode();
}
private void EnsureSecrets(YamlMappingNode root, RenderContext ctx)
{
var secretsKey = new YamlScalarNode("secrets");
YamlMappingNode secrets;
if (root.Children.ContainsKey(secretsKey))
secrets = (YamlMappingNode)root.Children[secretsKey];
else
{
secrets = new YamlMappingNode();
root.Children[secretsKey] = secrets;
}
foreach (var secretName in ctx.SecretNames)
{
var secretNode = new YamlMappingNode
{
{ "external", "true" }
};
secrets.Children[new YamlScalarNode(secretName)] = secretNode;
}
}
private static int GetServiceCount(YamlMappingNode root)
{
var servicesKey = new YamlScalarNode("services");
if (root.Children.ContainsKey(servicesKey) && root.Children[servicesKey] is YamlMappingNode svc)
return svc.Children.Count;
return 0;
}
}
/// <summary>
/// Context object with all inputs needed to render a Compose file.
/// </summary>
public class RenderContext
{
public string CustomerName { get; set; } = string.Empty;
public string StackName { get; set; } = string.Empty;
public string CmsServerName { get; set; } = string.Empty;
public int HostHttpPort { get; set; }
public string ThemeHostPath { get; set; } = string.Empty;
public string LibraryHostPath { get; set; } = string.Empty;
public string SmtpServer { get; set; } = string.Empty;
public string SmtpUsername { get; set; } = string.Empty;
public string TemplateYaml { get; set; } = string.Empty;
public List<string> TemplateEnvLines { get; set; } = new();
public List<string> Constraints { get; set; } = new();
/// <summary>Secret names to declare as external in the compose file.</summary>
public List<string> SecretNames { get; set; } = new();
}

View File

@@ -0,0 +1,127 @@
using YamlDotNet.RepresentationModel;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Validates a rendered Compose YAML before deployment.
/// Checks syntax, required structure, secrets references, and service presence.
/// </summary>
public class ComposeValidationService
{
private readonly ILogger<ComposeValidationService> _logger;
private static readonly HashSet<string> RequiredServices = new(StringComparer.OrdinalIgnoreCase)
{
"cms-db", "cms-web", "cms-memcached", "cms-quickchart"
};
public ComposeValidationService(ILogger<ComposeValidationService> logger)
{
_logger = logger;
}
/// <summary>
/// Validate a Compose YAML string; return errors (empty list = valid).
/// </summary>
public ValidationResult Validate(string composeYaml)
{
var errors = new List<string>();
var warnings = new List<string>();
// 1. YAML syntax
YamlStream yamlStream;
try
{
yamlStream = new YamlStream();
using var reader = new StringReader(composeYaml);
yamlStream.Load(reader);
}
catch (Exception ex)
{
errors.Add($"YAML parse error: {ex.Message}");
return new ValidationResult { Errors = errors, Warnings = warnings };
}
if (yamlStream.Documents.Count == 0)
{
errors.Add("YAML document is empty.");
return new ValidationResult { Errors = errors, Warnings = warnings };
}
var root = yamlStream.Documents[0].RootNode as YamlMappingNode;
if (root == null)
{
errors.Add("YAML root is not a mapping node.");
return new ValidationResult { Errors = errors, Warnings = warnings };
}
// 2. Required top-level keys
if (!HasKey(root, "services"))
errors.Add("Missing required top-level key: 'services'.");
if (!HasKey(root, "secrets"))
warnings.Add("Missing top-level key: 'secrets'. Secrets may not be available.");
// 3. Validate services
if (HasKey(root, "services") && root.Children[new YamlScalarNode("services")] is YamlMappingNode services)
{
var presentServices = services.Children.Keys
.OfType<YamlScalarNode>()
.Select(k => k.Value!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var required in RequiredServices)
{
if (!presentServices.Contains(required))
errors.Add($"Missing required service: '{required}'.");
}
// Check that XMR is NOT present
if (presentServices.Contains("cms-xmr"))
warnings.Add("Service 'cms-xmr' is present but not needed for Xibo CMS 4.4.0.");
// Validate each service has an image
foreach (var (key, value) in services.Children)
{
if (key is YamlScalarNode keyNode && value is YamlMappingNode svcNode)
{
if (!HasKey(svcNode, "image"))
errors.Add($"Service '{keyNode.Value}' is missing 'image'.");
}
}
}
// 4. Validate secrets section
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secrets)
{
foreach (var (key, value) in secrets.Children)
{
if (value is YamlMappingNode secretNode)
{
if (!HasKey(secretNode, "external"))
warnings.Add($"Secret '{((YamlScalarNode)key).Value}' is not marked as 'external: true'.");
}
}
}
// 5. Validate volumes section exists
if (!HasKey(root, "volumes"))
warnings.Add("Missing top-level key: 'volumes'. Named volumes may not be created.");
_logger.LogInformation("Compose validation: {ErrorCount} errors, {WarningCount} warnings",
errors.Count, warnings.Count);
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static bool HasKey(YamlMappingNode node, string key)
{
return node.Children.ContainsKey(new YamlScalarNode(key));
}
}
public class ValidationResult
{
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; set; } = new();
public List<string> Warnings { get; set; } = new();
}

View File

@@ -0,0 +1,201 @@
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Models.Entities;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Wraps docker CLI commands for stack deploy/rm/ls.
/// Requires docker binary on PATH and access to the Swarm manager (docker.sock).
/// </summary>
public class DockerCliService
{
private readonly DockerOptions _options;
private readonly ILogger<DockerCliService> _logger;
public DockerCliService(IOptions<DockerOptions> options, ILogger<DockerCliService> logger)
{
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Deploy a stack using docker stack deploy --compose-file - (stdin).
/// </summary>
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
{
var sw = Stopwatch.StartNew();
var args = $"stack deploy --compose-file -";
if (resolveImage)
args += " --resolve-image changed";
args += $" {stackName}";
_logger.LogInformation("Deploying stack: {StackName}", stackName);
var result = await RunDockerCommandAsync(args, composeYaml, _options.DeployTimeoutSeconds);
sw.Stop();
result.StackName = stackName;
result.DurationMs = sw.ElapsedMilliseconds;
if (result.Success)
_logger.LogInformation("Stack deployed: {StackName} | exit={ExitCode} | duration={DurationMs}ms",
stackName, result.ExitCode, result.DurationMs);
else
_logger.LogError("Stack deploy failed: {StackName} | exit={ExitCode} | error={Error}",
stackName, result.ExitCode, result.ErrorMessage);
return result;
}
/// <summary>
/// Remove a stack.
/// </summary>
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
{
var sw = Stopwatch.StartNew();
_logger.LogInformation("Removing stack: {StackName}", stackName);
var result = await RunDockerCommandAsync($"stack rm {stackName}", null, _options.DeployTimeoutSeconds);
sw.Stop();
result.StackName = stackName;
result.DurationMs = sw.ElapsedMilliseconds;
if (result.Success)
_logger.LogInformation("Stack removed: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
else
_logger.LogError("Stack remove failed: {StackName} | error={Error}", stackName, result.ErrorMessage);
return result;
}
/// <summary>
/// List all stacks.
/// </summary>
public async Task<List<StackInfo>> ListStacksAsync()
{
var result = await RunDockerCommandAsync("stack ls --format '{{.Name}}\\t{{.Services}}'", null, 10);
if (!result.Success || string.IsNullOrWhiteSpace(result.Output))
return new List<StackInfo>();
return result.Output
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 2);
return new StackInfo
{
Name = parts[0].Trim(),
ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0
};
})
.ToList();
}
/// <summary>
/// List services in a stack.
/// </summary>
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
{
var result = await RunDockerCommandAsync(
$"stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'",
null, 10);
if (!result.Success || string.IsNullOrWhiteSpace(result.Output))
return new List<ServiceInfo>();
return result.Output
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 3);
return new ServiceInfo
{
Name = parts.Length > 0 ? parts[0].Trim() : "",
Image = parts.Length > 1 ? parts[1].Trim() : "",
Replicas = parts.Length > 2 ? parts[2].Trim() : ""
};
})
.ToList();
}
private async Task<DeploymentResultDto> RunDockerCommandAsync(string arguments, string? stdin, int timeoutSeconds)
{
var psi = new ProcessStartInfo
{
FileName = "docker",
Arguments = arguments,
RedirectStandardInput = stdin != null,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
// Pass DOCKER_HOST if using a non-default socket
if (_options.SocketPath != "unix:///var/run/docker.sock")
{
psi.EnvironmentVariables["DOCKER_HOST"] = _options.SocketPath;
}
var result = new DeploymentResultDto();
try
{
using var process = Process.Start(psi)
?? throw new InvalidOperationException("Failed to start docker process.");
if (stdin != null)
{
await process.StandardInput.WriteAsync(stdin);
process.StandardInput.Close();
}
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
var completed = process.WaitForExit(timeoutSeconds * 1000);
if (!completed)
{
process.Kill(entireProcessTree: true);
result.Success = false;
result.ErrorMessage = $"Docker command timed out after {timeoutSeconds}s.";
result.Message = "Timeout";
return result;
}
result.Output = await stdoutTask;
result.ErrorMessage = await stderrTask;
result.ExitCode = process.ExitCode;
result.Success = process.ExitCode == 0;
result.Message = result.Success ? "Success" : "Failed";
}
catch (Exception ex)
{
_logger.LogError(ex, "Docker command execution failed: docker {Arguments}", arguments);
result.Success = false;
result.ErrorMessage = $"Failed to execute docker command: {ex.Message}";
result.Message = "Error";
}
return result;
}
}
public class StackInfo
{
public string Name { get; set; } = string.Empty;
public int ServiceCount { get; set; }
}
public class ServiceInfo
{
public string Name { get; set; } = string.Empty;
public string Image { get; set; } = string.Empty;
public string Replicas { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,122 @@
using System.Text;
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using Secret = Docker.DotNet.Models.Secret;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Manages Docker Swarm secrets via Docker.DotNet.
/// Creates, lists, and deletes secrets idempotently.
/// NEVER logs secret values — only names and IDs.
/// </summary>
public class DockerSecretsService : IDisposable
{
private readonly DockerClient _client;
private readonly ILogger<DockerSecretsService> _logger;
public DockerSecretsService(IOptions<DockerOptions> options, ILogger<DockerSecretsService> logger)
{
_logger = logger;
var socketPath = options.Value.SocketPath;
if (socketPath.StartsWith("unix://"))
{
_client = new DockerClientConfiguration(new Uri(socketPath)).CreateClient();
}
else if (socketPath.StartsWith("tcp://") || socketPath.StartsWith("http://"))
{
_client = new DockerClientConfiguration(new Uri(socketPath)).CreateClient();
}
else
{
_client = new DockerClientConfiguration().CreateClient();
}
}
/// <summary>
/// Create a secret if it doesn't exist. If it exists, optionally delete and recreate (rotate).
/// Docker secrets are immutable — update requires delete + recreate.
/// </summary>
public async Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false)
{
_logger.LogInformation("Ensuring secret exists: {SecretName}", name);
var existing = await FindSecretByNameAsync(name);
if (existing != null && !rotate)
{
_logger.LogDebug("Secret already exists: {SecretName} (id={SecretId})", name, existing.ID);
return (false, existing.ID);
}
if (existing != null && rotate)
{
_logger.LogInformation("Rotating secret: {SecretName} (old id={SecretId})", name, existing.ID);
await _client.Secrets.DeleteAsync(existing.ID);
}
var spec = new SecretSpec
{
Name = name,
Data = Encoding.UTF8.GetBytes(value).ToList()
};
var response = await _client.Secrets.CreateAsync(spec);
_logger.LogInformation("Secret created: {SecretName} (id={SecretId})", name, response.ID);
return (true, response.ID);
}
/// <summary>
/// List all secrets (metadata only — names and IDs, never values).
/// </summary>
public async Task<List<SecretListItem>> ListSecretsAsync()
{
var secrets = await _client.Secrets.ListAsync();
return secrets.Select(s => new SecretListItem
{
Id = s.ID,
Name = s.Spec.Name,
CreatedAt = s.CreatedAt
}).ToList();
}
/// <summary>
/// Delete a secret by name. Idempotent — returns success if not found.
/// </summary>
public async Task<bool> DeleteSecretAsync(string name)
{
var existing = await FindSecretByNameAsync(name);
if (existing == null)
{
_logger.LogDebug("Secret not found for deletion: {SecretName}", name);
return true; // idempotent
}
await _client.Secrets.DeleteAsync(existing.ID);
_logger.LogInformation("Secret deleted: {SecretName} (id={SecretId})", name, existing.ID);
return true;
}
private async Task<Secret?> FindSecretByNameAsync(string name)
{
var secrets = await _client.Secrets.ListAsync();
return secrets.FirstOrDefault(s =>
string.Equals(s.Spec.Name, name, StringComparison.OrdinalIgnoreCase));
}
public void Dispose()
{
_client.Dispose();
}
}
public class SecretListItem
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,178 @@
using System.Security.Cryptography;
using System.Text;
using LibGit2Sharp;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Models.DTOs;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Fetches template.yml and template.env from a Git repository using LibGit2Sharp.
/// Caches clones on disk, keyed by SHA-256 hash of the repo URL.
/// </summary>
public class GitTemplateService
{
private readonly GitOptions _options;
private readonly ILogger<GitTemplateService> _logger;
public GitTemplateService(IOptions<GitOptions> options, ILogger<GitTemplateService> logger)
{
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Fetch template.yml and template.env from a Git repo.
/// Uses cached clone if fresh; shallow clones or fetches as needed.
/// </summary>
public async Task<TemplateConfig> FetchAsync(string repoUrl, string? pat = null, bool forceRefresh = false)
{
var cacheKey = ComputeCacheKey(repoUrl);
var cacheDir = Path.Combine(_options.CacheDir, cacheKey);
_logger.LogInformation("Fetching templates from repo (cacheKey={CacheKey}, force={Force})", cacheKey, forceRefresh);
await Task.Run(() =>
{
if (!Directory.Exists(cacheDir) || !Repository.IsValid(cacheDir))
{
CloneRepo(repoUrl, pat, cacheDir);
}
else if (forceRefresh || IsCacheStale(cacheDir))
{
FetchLatest(repoUrl, pat, cacheDir);
}
else
{
_logger.LogDebug("Using cached clone at {CacheDir}", cacheDir);
}
});
var yamlPath = FindFile(cacheDir, "template.yml");
var envPath = FindFile(cacheDir, "template.env");
if (yamlPath == null)
throw new FileNotFoundException("template.yml not found in repository root.");
if (envPath == null)
throw new FileNotFoundException("template.env not found in repository root.");
var yaml = await File.ReadAllTextAsync(yamlPath);
var envLines = (await File.ReadAllLinesAsync(envPath))
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
.ToList();
return new TemplateConfig
{
Yaml = yaml,
EnvLines = envLines,
FetchedAt = DateTime.UtcNow
};
}
private void CloneRepo(string repoUrl, string? pat, string cacheDir)
{
_logger.LogInformation("Shallow cloning repo to {CacheDir}", cacheDir);
if (Directory.Exists(cacheDir))
Directory.Delete(cacheDir, recursive: true);
Directory.CreateDirectory(cacheDir);
var cloneOpts = new CloneOptions
{
IsBare = false,
RecurseSubmodules = false
};
if (!string.IsNullOrEmpty(pat))
{
cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) =>
new UsernamePasswordCredentials
{
Username = pat,
Password = string.Empty
};
}
try
{
Repository.Clone(repoUrl, cacheDir, cloneOpts);
}
catch (LibGit2SharpException ex)
{
_logger.LogError(ex, "Git clone failed for repo (cacheKey={CacheKey})", ComputeCacheKey(repoUrl));
throw new InvalidOperationException($"Failed to clone repository. Check URL and credentials. Error: {ex.Message}", ex);
}
}
private void FetchLatest(string repoUrl, string? pat, string cacheDir)
{
_logger.LogInformation("Fetching latest from origin in {CacheDir}", cacheDir);
try
{
using var repo = new Repository(cacheDir);
var fetchOpts = new FetchOptions();
if (!string.IsNullOrEmpty(pat))
{
fetchOpts.CredentialsProvider = (_, _, _) =>
new UsernamePasswordCredentials
{
Username = pat,
Password = string.Empty
};
}
var remote = repo.Network.Remotes["origin"];
var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification).ToArray();
Commands.Fetch(repo, "origin", refSpecs, fetchOpts, "Auto-fetch for template update");
// Fast-forward the default branch
var trackingBranch = repo.Head.TrackedBranch;
if (trackingBranch != null)
{
repo.Reset(ResetMode.Hard, trackingBranch.Tip);
}
// Update cache timestamp
WriteCacheTimestamp(cacheDir);
}
catch (LibGit2SharpException ex)
{
_logger.LogError(ex, "Git fetch failed for repo at {CacheDir}", cacheDir);
throw new InvalidOperationException($"Failed to fetch latest templates. Error: {ex.Message}", ex);
}
}
private bool IsCacheStale(string cacheDir)
{
var stampFile = Path.Combine(cacheDir, ".fetch-timestamp");
if (!File.Exists(stampFile)) return true;
if (DateTime.TryParse(File.ReadAllText(stampFile), out var lastFetch))
{
return (DateTime.UtcNow - lastFetch).TotalMinutes > _options.CacheTtlMinutes;
}
return true;
}
private static void WriteCacheTimestamp(string cacheDir)
{
var stampFile = Path.Combine(cacheDir, ".fetch-timestamp");
File.WriteAllText(stampFile, DateTime.UtcNow.ToString("O"));
}
private static string? FindFile(string dir, string fileName)
{
var path = Path.Combine(dir, fileName);
return File.Exists(path) ? path : null;
}
private static string ComputeCacheKey(string repoUrl)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(repoUrl));
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
}
}

View File

@@ -0,0 +1,433 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Models.Entities;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService,
/// DockerCliService, DockerSecretsService, and XiboApiService.
/// </summary>
public class InstanceService
{
private readonly XiboContext _db;
private readonly GitTemplateService _git;
private readonly ComposeRenderService _compose;
private readonly ComposeValidationService _validation;
private readonly DockerCliService _docker;
private readonly DockerSecretsService _secrets;
private readonly XiboApiService _xibo;
private readonly DockerOptions _dockerOptions;
private readonly ILogger<InstanceService> _logger;
public InstanceService(
XiboContext db,
GitTemplateService git,
ComposeRenderService compose,
ComposeValidationService validation,
DockerCliService docker,
DockerSecretsService secrets,
XiboApiService xibo,
IOptions<DockerOptions> dockerOptions,
ILogger<InstanceService> logger)
{
_db = db;
_git = git;
_compose = compose;
_validation = validation;
_docker = docker;
_secrets = secrets;
_xibo = xibo;
_dockerOptions = dockerOptions.Value;
_logger = logger;
}
/// <summary>
/// Create and deploy a new CMS instance.
/// </summary>
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null, string? ipAddress = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Create, userId, ipAddress);
try
{
_logger.LogInformation("Creating instance: stack={StackName}, customer={Customer}", dto.StackName, dto.CustomerName);
// 1. Validate no duplicate stack name
var existing = await _db.CmsInstances.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StackName == dto.StackName && i.DeletedAt == null);
if (existing != null)
throw new InvalidOperationException($"Stack '{dto.StackName}' already exists.");
// 2. Fetch templates from Git
var template = await _git.FetchAsync(dto.TemplateRepoUrl, dto.TemplateRepoPat);
// 3. Create Docker secrets
var smtpResult = await _secrets.EnsureSecretAsync(AppConstants.GlobalSmtpSecretName, dto.SmtpPassword);
var mysqlPassword = GenerateRandomPassword(32);
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(dto.CustomerName);
var mysqlResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
// Track secrets in DB
await EnsureSecretMetadata(AppConstants.GlobalSmtpSecretName, true, null);
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
// 4. Build render context
var constraints = dto.Constraints ?? new List<string>();
if (constraints.Count == 0)
constraints = _dockerOptions.DefaultConstraints;
var renderCtx = new RenderContext
{
CustomerName = dto.CustomerName,
StackName = dto.StackName,
CmsServerName = dto.CmsServerName,
HostHttpPort = dto.HostHttpPort,
ThemeHostPath = dto.ThemeHostPath,
LibraryHostPath = dto.LibraryHostPath,
SmtpServer = dto.SmtpServer,
SmtpUsername = dto.SmtpUsername,
TemplateYaml = template.Yaml,
TemplateEnvLines = template.EnvLines,
Constraints = constraints,
SecretNames = new List<string> { AppConstants.GlobalSmtpSecretName, mysqlSecretName }
};
// 5. Render Compose YAML
var composeYaml = _compose.Render(renderCtx);
// 6. Validate Compose
if (_dockerOptions.ValidateBeforeDeploy)
{
var validationResult = _validation.Validate(composeYaml);
if (!validationResult.IsValid)
{
var errorMsg = string.Join("; ", validationResult.Errors);
throw new InvalidOperationException($"Compose validation failed: {errorMsg}");
}
}
// 7. Deploy stack
var deployResult = await _docker.DeployStackAsync(dto.StackName, composeYaml);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
// 8. Store instance in DB
var instance = new CmsInstance
{
CustomerName = dto.CustomerName,
StackName = dto.StackName,
CmsServerName = dto.CmsServerName,
HostHttpPort = dto.HostHttpPort,
ThemeHostPath = dto.ThemeHostPath,
LibraryHostPath = dto.LibraryHostPath,
SmtpServer = dto.SmtpServer,
SmtpUsername = dto.SmtpUsername,
Constraints = JsonSerializer.Serialize(constraints),
TemplateRepoUrl = dto.TemplateRepoUrl,
TemplateRepoPat = dto.TemplateRepoPat,
TemplateLastFetch = template.FetchedAt,
Status = InstanceStatus.Active,
XiboUsername = dto.XiboUsername,
XiboPassword = dto.XiboPassword,
XiboApiTestStatus = XiboApiTestStatus.Unknown
};
_db.CmsInstances.Add(instance);
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance deployed: {dto.StackName}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
dto.StackName, instance.Id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = 4;
deployResult.Message = "Instance deployed successfully.";
return deployResult;
}
catch (Exception ex)
{
sw.Stop();
opLog.Status = OperationStatus.Failure;
opLog.Message = $"Create failed: {ex.Message}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogError(ex, "Instance create failed: {StackName}", dto.StackName);
throw;
}
}
/// <summary>
/// Update and redeploy an existing CMS instance.
/// </summary>
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null, string? ipAddress = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Update, userId, ipAddress);
try
{
var instance = await _db.CmsInstances.FindAsync(id)
?? throw new KeyNotFoundException($"Instance {id} not found.");
_logger.LogInformation("Updating instance: {StackName} (id={Id})", instance.StackName, id);
// Apply updates
if (dto.TemplateRepoUrl != null) instance.TemplateRepoUrl = dto.TemplateRepoUrl;
if (dto.TemplateRepoPat != null) instance.TemplateRepoPat = dto.TemplateRepoPat;
if (dto.SmtpServer != null) instance.SmtpServer = dto.SmtpServer;
if (dto.SmtpUsername != null) instance.SmtpUsername = dto.SmtpUsername;
if (dto.Constraints != null) instance.Constraints = JsonSerializer.Serialize(dto.Constraints);
if (dto.XiboUsername != null) instance.XiboUsername = dto.XiboUsername;
if (dto.XiboPassword != null) instance.XiboPassword = dto.XiboPassword;
// Re-fetch templates
var template = await _git.FetchAsync(instance.TemplateRepoUrl, instance.TemplateRepoPat, forceRefresh: true);
instance.TemplateLastFetch = template.FetchedAt;
// Re-render Compose
var constraints = string.IsNullOrEmpty(instance.Constraints)
? _dockerOptions.DefaultConstraints
: JsonSerializer.Deserialize<List<string>>(instance.Constraints) ?? _dockerOptions.DefaultConstraints;
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
var renderCtx = new RenderContext
{
CustomerName = instance.CustomerName,
StackName = instance.StackName,
CmsServerName = instance.CmsServerName,
HostHttpPort = instance.HostHttpPort,
ThemeHostPath = instance.ThemeHostPath,
LibraryHostPath = instance.LibraryHostPath,
SmtpServer = instance.SmtpServer,
SmtpUsername = instance.SmtpUsername,
TemplateYaml = template.Yaml,
TemplateEnvLines = template.EnvLines,
Constraints = constraints,
SecretNames = new List<string> { AppConstants.GlobalSmtpSecretName, mysqlSecretName }
};
var composeYaml = _compose.Render(renderCtx);
// Validate
if (_dockerOptions.ValidateBeforeDeploy)
{
var validationResult = _validation.Validate(composeYaml);
if (!validationResult.IsValid)
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
}
// Redeploy
var deployResult = await _docker.DeployStackAsync(instance.StackName, composeYaml, resolveImage: true);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack redeploy failed: {deployResult.ErrorMessage}");
instance.UpdatedAt = DateTime.UtcNow;
instance.Status = InstanceStatus.Active;
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance updated: {instance.StackName}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogInformation("Instance updated: {StackName} (id={Id}) | duration={DurationMs}ms",
instance.StackName, id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = 4;
deployResult.Message = "Instance updated and redeployed.";
return deployResult;
}
catch (Exception ex)
{
sw.Stop();
opLog.Status = OperationStatus.Failure;
opLog.Message = $"Update failed: {ex.Message}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogError(ex, "Instance update failed (id={Id})", id);
throw;
}
}
/// <summary>
/// Delete a CMS instance (soft delete in DB; removes stack from Swarm).
/// </summary>
public async Task<DeploymentResultDto> DeleteInstanceAsync(
Guid id, bool retainSecrets = false, bool clearXiboCreds = true,
string? userId = null, string? ipAddress = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Delete, userId, ipAddress);
try
{
var instance = await _db.CmsInstances.FindAsync(id)
?? throw new KeyNotFoundException($"Instance {id} not found.");
_logger.LogInformation("Deleting instance: {StackName} (id={Id}) retainSecrets={RetainSecrets}",
instance.StackName, id, retainSecrets);
// Remove stack
var result = await _docker.RemoveStackAsync(instance.StackName);
// Optionally remove secrets
if (!retainSecrets)
{
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
await _secrets.DeleteSecretAsync(mysqlSecretName);
var secretMeta = await _db.SecretMetadata
.FirstOrDefaultAsync(s => s.Name == mysqlSecretName);
if (secretMeta != null)
_db.SecretMetadata.Remove(secretMeta);
}
// Soft delete instance
instance.Status = InstanceStatus.Deleted;
instance.DeletedAt = DateTime.UtcNow;
instance.UpdatedAt = DateTime.UtcNow;
if (clearXiboCreds)
{
instance.XiboUsername = null;
instance.XiboPassword = null;
instance.XiboApiTestStatus = XiboApiTestStatus.Unknown;
}
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance deleted: {instance.StackName}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogInformation("Instance deleted: {StackName} (id={Id}) | duration={DurationMs}ms",
instance.StackName, id, sw.ElapsedMilliseconds);
result.Message = "Instance deleted.";
return result;
}
catch (Exception ex)
{
sw.Stop();
opLog.Status = OperationStatus.Failure;
opLog.Message = $"Delete failed: {ex.Message}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogError(ex, "Instance delete failed (id={Id})", id);
throw;
}
}
/// <summary>
/// Get an instance by ID.
/// </summary>
public async Task<CmsInstance?> GetInstanceAsync(Guid id)
{
return await _db.CmsInstances.FindAsync(id);
}
/// <summary>
/// List all active instances with optional filter.
/// </summary>
public async Task<(List<CmsInstance> Items, int TotalCount)> ListInstancesAsync(
int page = 1, int pageSize = 50, string? filter = null)
{
var query = _db.CmsInstances.AsQueryable();
if (!string.IsNullOrWhiteSpace(filter))
{
query = query.Where(i =>
i.CustomerName.Contains(filter) ||
i.StackName.Contains(filter));
}
var total = await query.CountAsync();
var items = await query
.OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, total);
}
/// <summary>
/// Test the Xibo API connection for an instance.
/// </summary>
public async Task<XiboTestResult> TestXiboConnectionAsync(Guid id)
{
var instance = await _db.CmsInstances.FindAsync(id)
?? throw new KeyNotFoundException($"Instance {id} not found.");
if (string.IsNullOrEmpty(instance.XiboUsername) || string.IsNullOrEmpty(instance.XiboPassword))
return new XiboTestResult { IsValid = false, Message = "No Xibo credentials stored." };
var url = $"http://localhost:{instance.HostHttpPort}";
var result = await _xibo.TestConnectionAsync(url, instance.XiboUsername, instance.XiboPassword);
instance.XiboApiTestStatus = result.IsValid ? XiboApiTestStatus.Success : XiboApiTestStatus.Failed;
instance.XiboApiTestedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return result;
}
private async Task EnsureSecretMetadata(string name, bool isGlobal, string? customerName)
{
var existing = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == name);
if (existing == null)
{
_db.SecretMetadata.Add(new SecretMetadata
{
Name = name,
IsGlobal = isGlobal,
CustomerName = customerName
});
}
}
private static OperationLog StartOperation(OperationType type, string? userId, string? ipAddress)
{
return new OperationLog
{
Operation = type,
UserId = userId,
IpAddress = ipAddress,
Status = OperationStatus.Pending
};
}
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
}

View File

@@ -0,0 +1,178 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Models.Entities;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Manages OIDC provider CRUD, test connections, and primary provider logic.
/// </summary>
public class OidcProviderService
{
private readonly XiboContext _db;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<OidcProviderService> _logger;
public OidcProviderService(
XiboContext db,
IHttpClientFactory httpClientFactory,
ILogger<OidcProviderService> logger)
{
_db = db;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<List<OidcProvider>> GetActiveProvidersAsync()
{
return await _db.OidcProviders
.Where(p => p.IsEnabled)
.OrderByDescending(p => p.IsPrimary)
.ThenBy(p => p.Name)
.ToListAsync();
}
public async Task<List<OidcProvider>> GetAllProvidersAsync()
{
return await _db.OidcProviders
.OrderByDescending(p => p.IsPrimary)
.ThenBy(p => p.Name)
.ToListAsync();
}
public async Task<OidcProvider?> GetProviderAsync(Guid id)
{
return await _db.OidcProviders.FindAsync(id);
}
public async Task<OidcProvider?> GetPrimaryProviderAsync()
{
return await _db.OidcProviders
.Where(p => p.IsEnabled && p.IsPrimary)
.FirstOrDefaultAsync()
?? await _db.OidcProviders
.Where(p => p.IsEnabled)
.FirstOrDefaultAsync();
}
public async Task<OidcProvider> CreateProviderAsync(CreateOidcProviderDto dto)
{
_logger.LogInformation("Creating OIDC provider: {Name}", dto.Name);
var existing = await _db.OidcProviders.FirstOrDefaultAsync(p => p.Name == dto.Name);
if (existing != null)
throw new InvalidOperationException($"OIDC provider with name '{dto.Name}' already exists.");
var provider = new OidcProvider
{
Name = dto.Name,
Authority = dto.Authority.TrimEnd('/'),
ClientId = dto.ClientId,
ClientSecret = dto.ClientSecret,
Audience = dto.Audience,
IsEnabled = dto.IsEnabled,
IsPrimary = dto.IsPrimary
};
// If setting as primary, clear existing primary
if (dto.IsPrimary)
await ClearPrimaryAsync();
_db.OidcProviders.Add(provider);
await _db.SaveChangesAsync();
_logger.LogInformation("OIDC provider created: {Name} (id={Id})", provider.Name, provider.Id);
return provider;
}
public async Task<OidcProvider> UpdateProviderAsync(Guid id, UpdateOidcProviderDto dto)
{
var provider = await _db.OidcProviders.FindAsync(id)
?? throw new KeyNotFoundException($"OIDC provider {id} not found.");
_logger.LogInformation("Updating OIDC provider: {Name} (id={Id})", provider.Name, id);
if (dto.Name != null) provider.Name = dto.Name;
if (dto.Authority != null) provider.Authority = dto.Authority.TrimEnd('/');
if (dto.ClientId != null) provider.ClientId = dto.ClientId;
if (dto.ClientSecret != null) provider.ClientSecret = dto.ClientSecret;
if (dto.Audience != null) provider.Audience = dto.Audience;
if (dto.IsEnabled.HasValue) provider.IsEnabled = dto.IsEnabled.Value;
if (dto.IsPrimary == true)
{
await ClearPrimaryAsync();
provider.IsPrimary = true;
}
else if (dto.IsPrimary == false)
{
provider.IsPrimary = false;
}
provider.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
_logger.LogInformation("OIDC provider updated: {Name} (id={Id})", provider.Name, id);
return provider;
}
public async Task DeleteProviderAsync(Guid id)
{
var provider = await _db.OidcProviders.FindAsync(id)
?? throw new KeyNotFoundException($"OIDC provider {id} not found.");
_logger.LogInformation("Deleting OIDC provider: {Name} (id={Id})", provider.Name, id);
_db.OidcProviders.Remove(provider);
await _db.SaveChangesAsync();
_logger.LogInformation("OIDC provider deleted: {Name}", provider.Name);
}
/// <summary>
/// Test that an OIDC provider's discovery endpoint is reachable.
/// </summary>
public async Task<(bool IsValid, string Message)> TestConnectionAsync(OidcProvider provider)
{
_logger.LogInformation("Testing OIDC provider: {Name} ({Authority})", provider.Name, provider.Authority);
try
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10);
var discoveryUrl = $"{provider.Authority.TrimEnd('/')}/.well-known/openid-configuration";
var response = await client.GetAsync(discoveryUrl);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
if (content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("OIDC provider test succeeded: {Name}", provider.Name);
return (true, "Connection successful. Discovery document found.");
}
return (false, "Endpoint reachable but response doesn't look like an OIDC discovery document.");
}
return (false, $"OIDC discovery endpoint returned HTTP {(int)response.StatusCode}.");
}
catch (TaskCanceledException)
{
return (false, "Connection timed out. Check the Authority URL.");
}
catch (HttpRequestException ex)
{
return (false, $"Cannot reach OIDC provider: {ex.Message}");
}
}
private async Task ClearPrimaryAsync()
{
var primaries = await _db.OidcProviders.Where(p => p.IsPrimary).ToListAsync();
foreach (var p in primaries)
p.IsPrimary = false;
}
}

View File

@@ -0,0 +1,132 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Communicates with deployed Xibo CMS instances via REST API.
/// Tests connectivity and provides stubs for future management operations.
/// NEVER logs passwords or credentials.
/// </summary>
public class XiboApiService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly XiboOptions _options;
private readonly ILogger<XiboApiService> _logger;
public XiboApiService(
IHttpClientFactory httpClientFactory,
IOptions<XiboOptions> options,
ILogger<XiboApiService> logger)
{
_httpClientFactory = httpClientFactory;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Test connection to a Xibo CMS instance using provided credentials.
/// Attempts OAuth2 client_credentials or resource-owner password grant.
/// </summary>
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
{
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
var client = _httpClientFactory.CreateClient("XiboApi");
client.Timeout = TimeSpan.FromSeconds(_options.TestConnectionTimeoutSeconds);
try
{
// Xibo CMS uses OAuth2. Try resource-owner password grant first.
var baseUrl = instanceUrl.TrimEnd('/');
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", username),
new KeyValuePair<string, string>("client_secret", password)
});
var response = await client.PostAsync(tokenUrl, formContent);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
return new XiboTestResult
{
IsValid = true,
Message = "Connected successfully.",
HttpStatus = (int)response.StatusCode
};
}
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
instanceUrl, (int)response.StatusCode);
return new XiboTestResult
{
IsValid = false,
Message = response.StatusCode switch
{
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
_ => $"Unexpected response: {(int)response.StatusCode}"
},
HttpStatus = (int)response.StatusCode
};
}
catch (TaskCanceledException)
{
_logger.LogWarning("Xibo connection test timed out: {InstanceUrl}", instanceUrl);
return new XiboTestResult
{
IsValid = false,
Message = "Connection timed out. Xibo instance may not be running.",
HttpStatus = 0
};
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Xibo connection test failed (network): {InstanceUrl}", instanceUrl);
return new XiboTestResult
{
IsValid = false,
Message = $"Cannot reach Xibo instance: {ex.Message}",
HttpStatus = 0
};
}
}
// --- Stubs for future management APIs ---
public Task<object?> GetLayoutsAsync(string instanceUrl, string accessToken)
{
_logger.LogDebug("GetLayouts stub called for {InstanceUrl}", instanceUrl);
return Task.FromResult<object?>(null);
}
public Task<object?> GetDisplaysAsync(string instanceUrl, string accessToken)
{
_logger.LogDebug("GetDisplays stub called for {InstanceUrl}", instanceUrl);
return Task.FromResult<object?>(null);
}
public Task<object?> GetSettingsAsync(string instanceUrl, string accessToken)
{
_logger.LogDebug("GetSettings stub called for {InstanceUrl}", instanceUrl);
return Task.FromResult<object?>(null);
}
}
public class XiboTestResult
{
public bool IsValid { get; set; }
public string Message { get; set; } = string.Empty;
public int HttpStatus { get; set; }
}

View File

@@ -0,0 +1,59 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" }
]
},
"FileLogging": {
"Enabled": true,
"Path": "/var/log/xibo-admin",
"RollingInterval": "Day",
"RetentionDays": 30,
"FileSizeLimitBytes": 104857600
},
"Authentication": {
"LocalAdminToken": ""
},
"Git": {
"CacheDir": "/var/cache/xibo-admin-templates",
"CacheTtlMinutes": 60,
"ShallowCloneDepth": 1
},
"Docker": {
"SocketPath": "unix:///var/run/docker.sock",
"DefaultConstraints": [ "node.labels.xibo==true" ],
"DeployTimeoutSeconds": 30,
"ValidateBeforeDeploy": true
},
"Xibo": {
"DefaultImages": {
"Cms": "ghcr.io/xibosignage/xibo-cms:release-4.4.0",
"Mysql": "mysql:8.4",
"Memcached": "memcached:alpine",
"QuickChart": "ianw/quickchart"
},
"TestConnectionTimeoutSeconds": 10
},
"Database": {
"Provider": "Sqlite"
},
"ConnectionStrings": {
"Default": "Data Source=xibo-admin.db"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<key id="5885a7e5-658b-4aeb-813b-26c439bd827f" version="1">
<creationDate>2026-02-12T18:59:07.274279Z</creationDate>
<activationDate>2026-02-12T18:59:07.274279Z</activationDate>
<expirationDate>2026-05-13T18:59:07.274279Z</expirationDate>
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>RT1UimqmAkfQCoJZHrRLxCFVZajsjGdpoBW5pUfxDcSVtowa//K7SBT4c/4ZXfe+6ZOWAOkwThrugOQQJZl5fw==</value>
</masterKey>
</descriptor>
</descriptor>
</key>

View File

@@ -0,0 +1,82 @@
# ==============================================================================
# OTSSignsOrchestrator - Docker Swarm Stack Definition
# ==============================================================================
# Deploy with: docker stack deploy -c stack.xibo-admin.yml ots-signs-orchestrator
#
# Prerequisites:
# 1. Docker Swarm initialized: docker swarm init
# 2. Create admin token secret: echo "your-secure-token" | docker secret create ots-signs-orchestrator-token -
# 3. Create log directory on host: mkdir -p /opt/ots-signs-orchestrator/logs
# 4. Build image: docker build -t ots-signs-orchestrator:latest ./OTSSignsOrchestrator
#
version: "3.8"
services:
xibo-admin:
image: ots-signs-orchestrator:latest
deploy:
replicas: 1
placement:
constraints:
- node.role == manager
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
window: 120s
resources:
limits:
memory: 512M
reservations:
memory: 128M
ports:
- "8088:8080"
volumes:
# Docker socket — required for stack deploy and secret management
- /var/run/docker.sock:/var/run/docker.sock:ro
# Persistent logs
- xibo-admin-logs:/app/logs
# SQLite database (dev) or template cache
- xibo-admin-data:/app/data
# Template cache
- xibo-admin-cache:/app/template-cache
environment:
- ASPNETCORE_ENVIRONMENT=Production
- Database__Provider=Sqlite
- Database__RunMigrationsOnStartup=true
- Authentication__AdminToken__Token=CHANGE_ME_USE_DOCKER_SECRET
- Git__CachePath=/app/template-cache
- Git__CacheTtlMinutes=60
- FileLogging__Path=/app/logs
- FileLogging__RetainDays=30
- Docker__SocketPath=unix:///var/run/docker.sock
# Alternatively, use Docker secrets for the admin token:
# secrets:
# - xibo-admin-token
# environment:
# - Authentication__AdminToken__Token_FILE=/run/secrets/xibo-admin-token
networks:
- xibo-admin-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
xibo-admin-logs:
driver: local
xibo-admin-data:
driver: local
xibo-admin-cache:
driver: local
networks:
xibo-admin-net:
driver: overlay
attachable: true
# secrets:
# xibo-admin-token:
# external: true

View File

@@ -0,0 +1,60 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More