Refactor code structure for improved readability and maintainability
This commit is contained in:
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal 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
22
OTSSignsOrchestrator.sln
Normal 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
|
||||||
8
OTSSignsOrchestrator/.dockerignore
Normal file
8
OTSSignsOrchestrator/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
logs/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
template-cache/
|
||||||
|
appsettings.*.local.json
|
||||||
76
OTSSignsOrchestrator/API/AuthController.cs
Normal file
76
OTSSignsOrchestrator/API/AuthController.cs
Normal 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;
|
||||||
|
}
|
||||||
134
OTSSignsOrchestrator/API/InstancesController.cs
Normal file
134
OTSSignsOrchestrator/API/InstancesController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
OTSSignsOrchestrator/API/LogsController.cs
Normal file
149
OTSSignsOrchestrator/API/LogsController.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
OTSSignsOrchestrator/API/OidcProvidersController.cs
Normal file
105
OTSSignsOrchestrator/API/OidcProvidersController.cs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
97
OTSSignsOrchestrator/API/SecretsController.cs
Normal file
97
OTSSignsOrchestrator/API/SecretsController.cs
Normal 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;
|
||||||
|
}
|
||||||
21
OTSSignsOrchestrator/Components/App.razor
Normal file
21
OTSSignsOrchestrator/Components/App.razor
Normal 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>
|
||||||
31
OTSSignsOrchestrator/Components/Layout/MainLayout.razor
Normal file
31
OTSSignsOrchestrator/Components/Layout/MainLayout.razor
Normal 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>
|
||||||
98
OTSSignsOrchestrator/Components/Layout/MainLayout.razor.css
Normal file
98
OTSSignsOrchestrator/Components/Layout/MainLayout.razor.css
Normal 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;
|
||||||
|
}
|
||||||
46
OTSSignsOrchestrator/Components/Layout/NavMenu.razor
Normal file
46
OTSSignsOrchestrator/Components/Layout/NavMenu.razor
Normal 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>
|
||||||
|
|
||||||
105
OTSSignsOrchestrator/Components/Layout/NavMenu.razor.css
Normal file
105
OTSSignsOrchestrator/Components/Layout/NavMenu.razor.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
OTSSignsOrchestrator/Components/Pages/Admin/Logs.razor
Normal file
198
OTSSignsOrchestrator/Components/Pages/Admin/Logs.razor
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
289
OTSSignsOrchestrator/Components/Pages/Admin/OidcProviders.razor
Normal file
289
OTSSignsOrchestrator/Components/Pages/Admin/OidcProviders.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
OTSSignsOrchestrator/Components/Pages/Admin/Secrets.razor
Normal file
213
OTSSignsOrchestrator/Components/Pages/Admin/Secrets.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
OTSSignsOrchestrator/Components/Pages/CreateInstance.razor
Normal file
215
OTSSignsOrchestrator/Components/Pages/CreateInstance.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
OTSSignsOrchestrator/Components/Pages/EditInstance.razor
Normal file
145
OTSSignsOrchestrator/Components/Pages/EditInstance.razor
Normal 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 — 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
OTSSignsOrchestrator/Components/Pages/Error.razor
Normal file
36
OTSSignsOrchestrator/Components/Pages/Error.razor
Normal 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;
|
||||||
|
}
|
||||||
199
OTSSignsOrchestrator/Components/Pages/Home.razor
Normal file
199
OTSSignsOrchestrator/Components/Pages/Home.razor
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
76
OTSSignsOrchestrator/Components/Pages/Login.razor
Normal file
76
OTSSignsOrchestrator/Components/Pages/Login.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
213
OTSSignsOrchestrator/Components/Pages/ViewInstance.razor
Normal file
213
OTSSignsOrchestrator/Components/Pages/ViewInstance.razor
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
6
OTSSignsOrchestrator/Components/Routes.razor
Normal file
6
OTSSignsOrchestrator/Components/Routes.razor
Normal 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>
|
||||||
15
OTSSignsOrchestrator/Components/_Imports.razor
Normal file
15
OTSSignsOrchestrator/Components/_Imports.razor
Normal 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
|
||||||
25
OTSSignsOrchestrator/Configuration/AppConstants.cs
Normal file
25
OTSSignsOrchestrator/Configuration/AppConstants.cs
Normal 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();
|
||||||
|
}
|
||||||
55
OTSSignsOrchestrator/Configuration/AppOptions.cs
Normal file
55
OTSSignsOrchestrator/Configuration/AppOptions.cs
Normal 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
|
||||||
|
}
|
||||||
169
OTSSignsOrchestrator/Configuration/DependencyInjection.cs
Normal file
169
OTSSignsOrchestrator/Configuration/DependencyInjection.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
OTSSignsOrchestrator/Data/XiboContext.cs
Normal file
80
OTSSignsOrchestrator/Data/XiboContext.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
53
OTSSignsOrchestrator/Dockerfile
Normal file
53
OTSSignsOrchestrator/Dockerfile
Normal 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"]
|
||||||
276
OTSSignsOrchestrator/Migrations/20260212185423_InitialCreate.Designer.cs
generated
Normal file
276
OTSSignsOrchestrator/Migrations/20260212185423_InitialCreate.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
OTSSignsOrchestrator/Migrations/20260212185423_InitialCreate.cs
Normal file
172
OTSSignsOrchestrator/Migrations/20260212185423_InitialCreate.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
273
OTSSignsOrchestrator/Migrations/XiboContextModelSnapshot.cs
Normal file
273
OTSSignsOrchestrator/Migrations/XiboContextModelSnapshot.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
OTSSignsOrchestrator/Models/DTOs/CreateInstanceDto.cs
Normal file
48
OTSSignsOrchestrator/Models/DTOs/CreateInstanceDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
13
OTSSignsOrchestrator/Models/DTOs/DeploymentResultDto.cs
Normal file
13
OTSSignsOrchestrator/Models/DTOs/DeploymentResultDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
47
OTSSignsOrchestrator/Models/DTOs/OidcProviderDtos.cs
Normal file
47
OTSSignsOrchestrator/Models/DTOs/OidcProviderDtos.cs
Normal 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; }
|
||||||
|
}
|
||||||
8
OTSSignsOrchestrator/Models/DTOs/TemplateConfig.cs
Normal file
8
OTSSignsOrchestrator/Models/DTOs/TemplateConfig.cs
Normal 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;
|
||||||
|
}
|
||||||
26
OTSSignsOrchestrator/Models/DTOs/UpdateInstanceDto.cs
Normal file
26
OTSSignsOrchestrator/Models/DTOs/UpdateInstanceDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
95
OTSSignsOrchestrator/Models/Entities/CmsInstance.cs
Normal file
95
OTSSignsOrchestrator/Models/Entities/CmsInstance.cs
Normal 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>();
|
||||||
|
}
|
||||||
35
OTSSignsOrchestrator/Models/Entities/OidcProvider.cs
Normal file
35
OTSSignsOrchestrator/Models/Entities/OidcProvider.cs
Normal 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;
|
||||||
|
}
|
||||||
58
OTSSignsOrchestrator/Models/Entities/OperationLog.cs
Normal file
58
OTSSignsOrchestrator/Models/Entities/OperationLog.cs
Normal 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; }
|
||||||
|
}
|
||||||
21
OTSSignsOrchestrator/Models/Entities/SecretMetadata.cs
Normal file
21
OTSSignsOrchestrator/Models/Entities/SecretMetadata.cs
Normal 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; }
|
||||||
|
}
|
||||||
25
OTSSignsOrchestrator/OTSSignsOrchestrator.csproj
Normal file
25
OTSSignsOrchestrator/OTSSignsOrchestrator.csproj
Normal 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>
|
||||||
45
OTSSignsOrchestrator/Program.cs
Normal file
45
OTSSignsOrchestrator/Program.cs
Normal 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();
|
||||||
23
OTSSignsOrchestrator/Properties/launchSettings.json
Normal file
23
OTSSignsOrchestrator/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
OTSSignsOrchestrator/Services/AdminTokenAuthHandler.cs
Normal file
54
OTSSignsOrchestrator/Services/AdminTokenAuthHandler.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
363
OTSSignsOrchestrator/Services/ComposeRenderService.cs
Normal file
363
OTSSignsOrchestrator/Services/ComposeRenderService.cs
Normal 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();
|
||||||
|
}
|
||||||
127
OTSSignsOrchestrator/Services/ComposeValidationService.cs
Normal file
127
OTSSignsOrchestrator/Services/ComposeValidationService.cs
Normal 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();
|
||||||
|
}
|
||||||
201
OTSSignsOrchestrator/Services/DockerCliService.cs
Normal file
201
OTSSignsOrchestrator/Services/DockerCliService.cs
Normal 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;
|
||||||
|
}
|
||||||
122
OTSSignsOrchestrator/Services/DockerSecretsService.cs
Normal file
122
OTSSignsOrchestrator/Services/DockerSecretsService.cs
Normal 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; }
|
||||||
|
}
|
||||||
178
OTSSignsOrchestrator/Services/GitTemplateService.cs
Normal file
178
OTSSignsOrchestrator/Services/GitTemplateService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
433
OTSSignsOrchestrator/Services/InstanceService.cs
Normal file
433
OTSSignsOrchestrator/Services/InstanceService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
OTSSignsOrchestrator/Services/OidcProviderService.cs
Normal file
178
OTSSignsOrchestrator/Services/OidcProviderService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
OTSSignsOrchestrator/Services/XiboApiService.cs
Normal file
132
OTSSignsOrchestrator/Services/XiboApiService.cs
Normal 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; }
|
||||||
|
}
|
||||||
59
OTSSignsOrchestrator/appsettings.json
Normal file
59
OTSSignsOrchestrator/appsettings.json
Normal 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": "*"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
82
OTSSignsOrchestrator/stack.xibo-admin.yml
Normal file
82
OTSSignsOrchestrator/stack.xibo-admin.yml
Normal 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
|
||||||
60
OTSSignsOrchestrator/wwwroot/app.css
Normal file
60
OTSSignsOrchestrator/wwwroot/app.css
Normal 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;
|
||||||
|
}
|
||||||
BIN
OTSSignsOrchestrator/wwwroot/favicon.png
Normal file
BIN
OTSSignsOrchestrator/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
4085
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
4085
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4084
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
vendored
Normal file
4084
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
597
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
vendored
Normal file
597
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
vendored
Normal 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 */
|
||||||
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
594
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
vendored
Normal file
594
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
vendored
Normal 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 */
|
||||||
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
5402
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
vendored
Normal file
5402
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
5393
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
vendored
Normal file
5393
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12057
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
Normal file
12057
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12030
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
vendored
Normal file
12030
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
vendored
Normal file
6
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6314
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
vendored
Normal file
6314
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
vendored
Normal file
7
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4447
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
vendored
Normal file
4447
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
vendored
Normal file
7
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4494
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.js
vendored
Normal file
4494
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
vendored
Normal file
1
OTSSignsOrchestrator/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
vendored
Normal file
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
Reference in New Issue
Block a user