feat: Add main application views and structure
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

- Implemented CreateInstanceView for creating new instances.
- Added HostsView for managing SSH hosts with CRUD operations.
- Created InstancesView for displaying and managing instances.
- Developed LogsView for viewing operation logs.
- Introduced SecretsView for managing secrets associated with hosts.
- Established SettingsView for configuring application settings.
- Created MainWindow as the main application window with navigation.
- Added app manifest and configuration files for logging and settings.
This commit is contained in:
Matt Batchelder
2026-02-18 10:43:27 -05:00
parent 29b8c23dbb
commit 45c94b6536
149 changed files with 6469 additions and 63498 deletions

View File

@@ -0,0 +1,55 @@
# Gitea Actions workflow: build Docker image and push to a container registry
# Place secrets in the repository settings: REGISTRY (host[:port]), IMAGE_NAME, DOCKER_USERNAME, DOCKER_PASSWORD
name: Build and Publish Docker Image
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
build-and-push:
# Use an appropriate runner that has Docker available (self-hosted runner)
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build and push image
# run everything in a single shell step to keep tag calculation simple
run: |
set -euo pipefail
REGISTRY="${{ secrets.REGISTRY }}"
IMAGE_NAME="${{ secrets.IMAGE_NAME }}"
DOCKER_USERNAME="${{ secrets.DOCKER_USERNAME }}"
DOCKER_PASSWORD="${{ secrets.DOCKER_PASSWORD }}"
if [ -z "$REGISTRY" ] || [ -z "$IMAGE_NAME" ]; then
echo "Missing required secrets: REGISTRY and IMAGE_NAME must be set." >&2
exit 1
fi
TAG=$(git rev-parse --short HEAD)
IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
LATEST="$REGISTRY/$IMAGE_NAME:latest"
echo "Logging in to $REGISTRY"
echo "$DOCKER_PASSWORD" | docker login "$REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
echo "Building $IMAGE (and tagging as latest)"
docker build -t "$IMAGE" -t "$LATEST" .
echo "Pushing $IMAGE"
docker push "$IMAGE"
echo "Pushing $LATEST"
docker push "$LATEST"
env:
# secrets are available via ${{ secrets.<name> }} in Gitea Actions
REGISTRY: ${{ secrets.REGISTRY }}
IMAGE_NAME: ${{ secrets.IMAGE_NAME }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -1,4 +1,4 @@
namespace OTSSignsOrchestrator.Configuration;
namespace OTSSignsOrchestrator.Core.Configuration;
/// <summary>
/// Shared constants for the application.
@@ -8,10 +8,6 @@ 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";

View File

@@ -0,0 +1,76 @@
namespace OTSSignsOrchestrator.Core.Configuration;
public class FileLoggingOptions
{
public const string SectionName = "FileLogging";
public bool Enabled { get; set; } = true;
public string Path { get; set; } = "logs";
public string RollingInterval { get; set; } = "Day";
public int RetentionDays { get; set; } = 30;
public long FileSizeLimitBytes { get; set; } = 100 * 1024 * 1024; // 100MB
}
public class GitOptions
{
public const string SectionName = "Git";
public string CacheDir { get; set; } = ".template-cache";
public int CacheTtlMinutes { get; set; } = 60;
public int ShallowCloneDepth { get; set; } = 1;
}
public class DockerOptions
{
public const string SectionName = "Docker";
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";
}
public class InstanceDefaultsOptions
{
public const string SectionName = "InstanceDefaults";
/// <summary>Default template repo URL if not overridden per-instance.</summary>
public string? TemplateRepoUrl { get; set; }
public string? TemplateRepoPat { get; set; }
/// <summary>Template for CMS server hostname. Use {abbrev} as placeholder.</summary>
public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com";
public string SmtpServer { get; set; } = string.Empty;
public string SmtpUsername { get; set; } = string.Empty;
public string SmtpPassword { get; set; } = string.Empty;
public int BaseHostHttpPort { get; set; } = 8080;
/// <summary>Template for theme path. Use {abbrev} as placeholder.</summary>
/// <summary>Static host path for the theme volume mount. Overridable per-instance.</summary>
public string ThemeHostPath { get; set; } = "/cms/ots-theme";
/// <summary>Subfolder name on CIFS share for the library volume. Use {abbrev} as placeholder.</summary>
public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library";
public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db";
public string MySqlUserTemplate { get; set; } = "{abbrev}_cms";
}

View File

@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
namespace OTSSignsOrchestrator.Core.Data;
/// <summary>
/// Design-time factory for EF Core migrations tooling.
/// </summary>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<XiboContext>
{
public XiboContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<XiboContext>();
optionsBuilder.UseSqlite("Data Source=design-time.db");
// Set up a temporary DataProtection provider for design-time use
var services = new ServiceCollection();
services.AddDataProtection()
.SetApplicationName("OTSSignsOrchestrator");
var sp = services.BuildServiceProvider();
var dpProvider = sp.GetRequiredService<IDataProtectionProvider>();
return new XiboContext(optionsBuilder.Options, dpProvider);
}
}

View File

@@ -1,9 +1,9 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Models.Entities;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Data;
namespace OTSSignsOrchestrator.Core.Data;
public class XiboContext : DbContext
{
@@ -16,9 +16,10 @@ public class XiboContext : DbContext
}
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
public DbSet<OidcProvider> OidcProviders => Set<OidcProvider>();
public DbSet<SshHost> SshHosts => Set<SshHost>();
public DbSet<SecretMetadata> SecretMetadata => Set<SecretMetadata>();
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -29,11 +30,13 @@ public class XiboContext : DbContext
{
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
entity.HasOne(e => e.SshHost)
.WithMany(h => h.Instances)
.HasForeignKey(e => e.SshHostId)
.OnDelete(DeleteBehavior.SetNull);
if (_dataProtection != null)
{
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.CmsInstance");
@@ -44,22 +47,27 @@ public class XiboContext : DbContext
entity.Property(e => e.XiboPassword).HasConversion(pwdConverter);
entity.Property(e => e.XiboUsername).HasConversion(pwdConverter);
entity.Property(e => e.TemplateRepoPat).HasConversion(pwdConverter);
entity.Property(e => e.CifsPassword).HasConversion(pwdConverter);
}
});
// --- OidcProvider ---
modelBuilder.Entity<OidcProvider>(entity =>
// --- SshHost ---
modelBuilder.Entity<SshHost>(entity =>
{
entity.HasIndex(e => e.Name).IsUnique();
entity.HasIndex(e => e.Label).IsUnique();
if (_dataProtection != null)
{
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.OidcProvider");
var secretConverter = new ValueConverter<string, string>(
v => protector.Protect(v),
v => protector.Unprotect(v));
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.SshHost");
var passphraseConverter = new ValueConverter<string?, string?>(
v => v != null ? protector.Protect(v) : null,
v => v != null ? protector.Unprotect(v) : null);
var passwordConverter = new ValueConverter<string?, string?>(
v => v != null ? protector.Protect(v) : null,
v => v != null ? protector.Unprotect(v) : null);
entity.Property(e => e.ClientSecret).HasConversion(secretConverter);
entity.Property(e => e.KeyPassphrase).HasConversion(passphraseConverter);
entity.Property(e => e.Password).HasConversion(passwordConverter);
}
});
@@ -76,5 +84,12 @@ public class XiboContext : DbContext
entity.HasIndex(e => e.InstanceId);
entity.HasIndex(e => e.Operation);
});
// --- AppSetting ---
modelBuilder.Entity<AppSetting>(entity =>
{
entity.HasKey(e => e.Key);
entity.HasIndex(e => e.Category);
});
}
}

View File

@@ -4,15 +4,15 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260212185423_InitialCreate")]
partial class InitialCreate
[Migration("20260217004115_DesktopInitial")]
partial class DesktopInitial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -20,7 +20,7 @@ namespace OTSSignsOrchestrator.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.CmsInstance", b =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -64,6 +64,9 @@ namespace OTSSignsOrchestrator.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
@@ -114,63 +117,15 @@ namespace OTSSignsOrchestrator.Migrations
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
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 =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -182,10 +137,6 @@ namespace OTSSignsOrchestrator.Migrations
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
@@ -193,9 +144,6 @@ namespace OTSSignsOrchestrator.Migrations
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<Guid?>("ProviderId")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
@@ -212,14 +160,12 @@ namespace OTSSignsOrchestrator.Migrations
b.HasIndex("Operation");
b.HasIndex("ProviderId");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -251,25 +197,93 @@ namespace OTSSignsOrchestrator.Migrations
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.HasOne("XiboSwarmAdmin.Models.Entities.CmsInstance", "Instance")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.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 =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}

View File

@@ -3,14 +3,53 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
public partial class DesktopInitial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
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: "SshHosts",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Label = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Host = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
Port = table.Column<int>(type: "INTEGER", nullable: false),
Username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
PrivateKeyPath = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
KeyPassphrase = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
Password = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
UseKeyAuth = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
LastTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
LastTestSuccess = table.Column<bool>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SshHosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CmsInstances",
columns: table => new
@@ -36,47 +75,18 @@ namespace OTSSignsOrchestrator.Migrations
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)
DeletedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
SshHostId = table.Column<Guid>(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);
table.ForeignKey(
name: "FK_CmsInstances_SshHosts_SshHostId",
column: x => x.SshHostId,
principalTable: "SshHosts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
@@ -86,13 +96,11 @@ namespace OTSSignsOrchestrator.Migrations
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)
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
@@ -102,11 +110,6 @@ namespace OTSSignsOrchestrator.Migrations
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(
@@ -114,18 +117,17 @@ namespace OTSSignsOrchestrator.Migrations
table: "CmsInstances",
column: "CustomerName");
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_SshHostId",
table: "CmsInstances",
column: "SshHostId");
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",
@@ -136,11 +138,6 @@ namespace OTSSignsOrchestrator.Migrations
table: "OperationLogs",
column: "Operation");
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_ProviderId",
table: "OperationLogs",
column: "ProviderId");
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_Timestamp",
table: "OperationLogs",
@@ -151,6 +148,12 @@ namespace OTSSignsOrchestrator.Migrations
table: "SecretMetadata",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SshHosts_Label",
table: "SshHosts",
column: "Label",
unique: true);
}
/// <inheritdoc />
@@ -166,7 +169,7 @@ namespace OTSSignsOrchestrator.Migrations
name: "CmsInstances");
migrationBuilder.DropTable(
name: "OidcProviders");
name: "SshHosts");
}
}
}

View File

@@ -2,22 +2,25 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
partial class XiboContextModelSnapshot : ModelSnapshot
[Migration("20260218140239_AddCustomerAbbrev")]
partial class AddCustomerAbbrev
{
protected override void BuildModel(ModelBuilder modelBuilder)
/// <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 =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -35,6 +38,11 @@ namespace OTSSignsOrchestrator.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
@@ -61,6 +69,9 @@ namespace OTSSignsOrchestrator.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
@@ -111,63 +122,15 @@ namespace OTSSignsOrchestrator.Migrations
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
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 =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -179,10 +142,6 @@ namespace OTSSignsOrchestrator.Migrations
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
@@ -190,9 +149,6 @@ namespace OTSSignsOrchestrator.Migrations
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<Guid?>("ProviderId")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
@@ -209,14 +165,12 @@ namespace OTSSignsOrchestrator.Migrations
b.HasIndex("Operation");
b.HasIndex("ProviderId");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.SecretMetadata", b =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -248,25 +202,93 @@ namespace OTSSignsOrchestrator.Migrations
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("XiboSwarmAdmin.Models.Entities.OperationLog", b =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.HasOne("XiboSwarmAdmin.Models.Entities.CmsInstance", "Instance")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.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 =>
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddCustomerAbbrev : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CustomerAbbrev",
table: "CmsInstances",
type: "TEXT",
maxLength: 3,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CustomerAbbrev",
table: "CmsInstances");
}
}
}

View File

@@ -0,0 +1,323 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218143812_AddAppSettings")]
partial class AddAppSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.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>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.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<Guid?>("SshHostId")
.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("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.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>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
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("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.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("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddAppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppSettings",
columns: table => new
{
Key = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Value = table.Column<string>(type: "TEXT", maxLength: 4000, nullable: true),
Category = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
IsSensitive = table.Column<bool>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppSettings", x => x.Key);
});
migrationBuilder.CreateIndex(
name: "IX_AppSettings_Category",
table: "AppSettings",
column: "Category");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppSettings");
}
}
}

View File

@@ -0,0 +1,343 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218144537_AddPerInstanceCifsCredentials")]
partial class AddPerInstanceCifsCredentials
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareBasePath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.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>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.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<Guid?>("SshHostId")
.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("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.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>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
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("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.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("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddPerInstanceCifsCredentials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CifsExtraOptions",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsPassword",
table: "CmsInstances",
type: "TEXT",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsServer",
table: "CmsInstances",
type: "TEXT",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsShareBasePath",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsUsername",
table: "CmsInstances",
type: "TEXT",
maxLength: 200,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CifsExtraOptions",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsPassword",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsServer",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsShareBasePath",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsUsername",
table: "CmsInstances");
}
}
}

View File

@@ -0,0 +1,340 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.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("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareBasePath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.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>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.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<Guid?>("SshHostId")
.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("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.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>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
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("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.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("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Core.Models.DTOs;
public class CreateInstanceDto
{
[Required, MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
/// <summary>Exactly 3 lowercase letters used to derive all resource names.</summary>
[Required, MaxLength(3), MinLength(3), RegularExpression("^[a-z]{3}$", ErrorMessage = "Abbreviation must be exactly 3 lowercase letters.")]
public string CustomerAbbrev { get; set; } = string.Empty;
/// <summary>SSH host to deploy to.</summary>
public Guid? SshHostId { get; set; }
/// <summary>Pangolin Newt ID (optional — tunnel service excluded if not provided).</summary>
[MaxLength(500)]
public string? NewtId { get; set; }
/// <summary>Pangolin Newt Secret (optional — tunnel service excluded if not provided).</summary>
[MaxLength(500)]
public string? NewtSecret { get; set; }
// ── CIFS / SMB credentials (optional — falls back to global settings) ──
[MaxLength(200)]
public string? CifsServer { get; set; }
[MaxLength(500)]
public string? CifsShareBasePath { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }
[MaxLength(500)]
public string? CifsPassword { get; set; }
[MaxLength(500)]
public string? CifsExtraOptions { get; set; }
}

View File

@@ -1,4 +1,4 @@
namespace OTSSignsOrchestrator.Models.DTOs;
namespace OTSSignsOrchestrator.Core.Models.DTOs;
public class DeploymentResultDto
{

View File

@@ -1,4 +1,4 @@
namespace OTSSignsOrchestrator.Models.DTOs;
namespace OTSSignsOrchestrator.Core.Models.DTOs;
public class TemplateConfig
{

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.DTOs;
namespace OTSSignsOrchestrator.Core.Models.DTOs;
public class UpdateInstanceDto
{
@@ -23,4 +23,21 @@ public class UpdateInstanceDto
[MaxLength(200)]
public string? XiboPassword { get; set; }
// ── CIFS / SMB credentials (per-instance) ──
[MaxLength(200)]
public string? CifsServer { get; set; }
[MaxLength(500)]
public string? CifsShareBasePath { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }
[MaxLength(500)]
public string? CifsPassword { get; set; }
[MaxLength(500)]
public string? CifsExtraOptions { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Core.Models.Entities;
/// <summary>
/// Key-value application setting persisted in the local database.
/// Sensitive values are encrypted at rest via DataProtection.
/// </summary>
public class AppSetting
{
[Key, MaxLength(200)]
public string Key { get; set; } = string.Empty;
[MaxLength(4000)]
public string? Value { get; set; }
[Required, MaxLength(50)]
public string Category { get; set; } = string.Empty;
public bool IsSensitive { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,117 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OTSSignsOrchestrator.Core.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;
/// <summary>Exactly 3 lowercase letters used to derive all resource names.</summary>
[MaxLength(3)]
public string CustomerAbbrev { 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. Never logged; encrypted at rest.</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; }
// ── CIFS / SMB credentials (per-instance) ─────────────────────────────
[MaxLength(200)]
public string? CifsServer { get; set; }
[MaxLength(500)]
public string? CifsShareBasePath { get; set; }
[MaxLength(200)]
public string? CifsUsername { get; set; }
/// <summary>Encrypted CIFS password. Never logged; encrypted at rest.</summary>
[MaxLength(1000)]
public string? CifsPassword { get; set; }
[MaxLength(500)]
public string? CifsExtraOptions { get; set; }
/// <summary>ID of the SshHost this instance is deployed to.</summary>
public Guid? SshHostId { get; set; }
[ForeignKey(nameof(SshHostId))]
public SshHost? SshHost { get; set; }
public ICollection<OperationLog> OperationLogs { get; set; } = new List<OperationLog>();
}

View File

@@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OTSSignsOrchestrator.Models.Entities;
namespace OTSSignsOrchestrator.Core.Models.Entities;
public enum OperationType
{
@@ -33,26 +33,16 @@ public class OperationLog
[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>
/// <summary>Human-readable message. NEVER includes secret values.</summary>
[MaxLength(2000)]
public string? Message { get; set; }
public long? DurationMs { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[MaxLength(50)]
public string? IpAddress { get; set; }
}

View File

@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Models.Entities;
namespace OTSSignsOrchestrator.Core.Models.Entities;
public class SecretMetadata
{

View File

@@ -0,0 +1,59 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Core.Models.Entities;
/// <summary>
/// Represents a remote Docker Swarm host accessible over SSH.
/// SSH key paths or encrypted passwords are stored for authentication.
/// </summary>
public class SshHost
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required, MaxLength(200)]
public string Label { get; set; } = string.Empty;
[Required, MaxLength(500)]
public string Host { get; set; } = string.Empty;
[Range(1, 65535)]
public int Port { get; set; } = 22;
[Required, MaxLength(100)]
public string Username { get; set; } = string.Empty;
/// <summary>
/// Path to the SSH private key file on the local machine.
/// </summary>
[MaxLength(1000)]
public string? PrivateKeyPath { get; set; }
/// <summary>
/// Encrypted passphrase for the SSH key (if any). Protected by DataProtection.
/// </summary>
[MaxLength(2000)]
public string? KeyPassphrase { get; set; }
/// <summary>
/// Encrypted SSH password (if key-based auth is not used). Protected by DataProtection.
/// </summary>
[MaxLength(2000)]
public string? Password { get; set; }
/// <summary>
/// Whether to prefer key-based auth over password.
/// </summary>
public bool UseKeyAuth { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Last time a connection was successfully tested.</summary>
public DateTime? LastTestedAt { get; set; }
public bool? LastTestSuccess { get; set; }
public ICollection<CmsInstance> Instances { get; set; } = new List<CmsInstance>();
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -7,16 +7,18 @@
</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.AspNetCore.DataProtection" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" 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="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" 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" />

View File

@@ -0,0 +1,361 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Generates a Docker Compose v3.9 YAML for a Xibo CMS stack.
/// Combined format: no separate config.env, no MySQL container (external DB),
/// CIFS volumes, Newt tunnel service, and inline environment variables.
/// </summary>
public class ComposeRenderService
{
private readonly ILogger<ComposeRenderService> _logger;
public ComposeRenderService(ILogger<ComposeRenderService> logger)
{
_logger = logger;
}
public string Render(RenderContext ctx)
{
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
var root = new YamlMappingNode();
// Version
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
// Comment — customer name (added as a YAML comment isn't natively supported,
// so we prepend it manually after serialization)
BuildServices(root, ctx);
BuildNetworks(root, ctx);
BuildVolumes(root, ctx);
BuildSecrets(root, ctx);
var doc = new YamlDocument(root);
var stream = new YamlStream(doc);
using var writer = new StringWriter();
stream.Save(writer, assignAnchors: false);
var output = writer.ToString()
.Replace("...\n", "").Replace("...", "");
// Prepend customer name comment
output = $"# Customer: {ctx.CustomerName}\n{output}";
_logger.LogDebug("Compose rendered for {StackName}: {ServiceCount} services",
ctx.StackName, 4);
return output;
}
// ── Services ────────────────────────────────────────────────────────────
private void BuildServices(YamlMappingNode root, RenderContext ctx)
{
var services = new YamlMappingNode();
root.Children[new YamlScalarNode("services")] = services;
BuildWebService(services, ctx);
BuildMemcachedService(services, ctx);
BuildQuickChartService(services, ctx);
if (ctx.IncludeNewt)
BuildNewtService(services, ctx);
}
private void BuildWebService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-web")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.CmsImage);
// Environment — all config.env values merged inline
var env = new YamlMappingNode
{
{ "CMS_USE_MEMCACHED", "true" },
{ "MEMCACHED_HOST", "memcached" },
{ "MYSQL_HOST", ctx.MySqlHost },
{ "MYSQL_PORT", ctx.MySqlPort },
{ "MYSQL_DATABASE", ctx.MySqlDatabase },
{ "MYSQL_USER", ctx.MySqlUser },
{ "MYSQL_PASSWORD_FILE", $"/run/secrets/{ctx.CustomerAbbrev}-cms-db-password" },
{ "CMS_SMTP_SERVER", ctx.SmtpServer },
{ "CMS_SMTP_USERNAME", ctx.SmtpUsername },
{ "CMS_SMTP_PASSWORD", ctx.SmtpPassword },
{ "CMS_SMTP_USE_TLS", ctx.SmtpUseTls },
{ "CMS_SMTP_USE_STARTTLS", ctx.SmtpUseStartTls },
{ "CMS_SMTP_REWRITE_DOMAIN", ctx.SmtpRewriteDomain },
{ "CMS_SMTP_HOSTNAME", ctx.SmtpHostname },
{ "CMS_SMTP_FROM_LINE_OVERRIDE", ctx.SmtpFromLineOverride },
{ "CMS_SERVER_NAME", ctx.CmsServerName },
{ "CMS_PHP_POST_MAX_SIZE", ctx.PhpPostMaxSize },
{ "CMS_PHP_UPLOAD_MAX_FILESIZE", ctx.PhpUploadMaxFilesize },
{ "CMS_PHP_MAX_EXECUTION_TIME", ctx.PhpMaxExecutionTime },
};
svc.Children[new YamlScalarNode("environment")] = env;
// Secrets
var secrets = new YamlSequenceNode(
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-db-password")
);
svc.Children[new YamlScalarNode("secrets")] = secrets;
// Volumes
var volumes = new YamlSequenceNode(
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-custom:/var/www/cms/custom"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-backup:/var/www/backup"),
new YamlScalarNode($"{ctx.ThemeHostPath}:/var/www/cms/web/theme/custom"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-library:/var/www/cms/library"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-userscripts:/var/www/cms/web/userscripts"),
new YamlScalarNode($"{ctx.CustomerAbbrev}-cms-ca-certs:/var/www/cms/ca-certs")
);
svc.Children[new YamlScalarNode("volumes")] = volumes;
// Ports
var ports = new YamlSequenceNode(
new YamlScalarNode($"{ctx.HostHttpPort}:80")
);
svc.Children[new YamlScalarNode("ports")] = ports;
// Networks
var webNet = new YamlMappingNode
{
{ "aliases", new YamlSequenceNode(new YamlScalarNode("web")) }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = webNet;
svc.Children[new YamlScalarNode("networks")] = networks;
// Deploy
var deploy = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
{ "resources", new YamlMappingNode
{ { "limits", new YamlMappingNode { { "memory", "1G" } } } }
}
};
svc.Children[new YamlScalarNode("deploy")] = deploy;
}
private void BuildMemcachedService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-memcached")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.MemcachedImage);
var command = new YamlSequenceNode(
new YamlScalarNode("memcached"),
new YamlScalarNode("-m"),
new YamlScalarNode("15")
);
svc.Children[new YamlScalarNode("command")] = command;
var mcNet = new YamlMappingNode
{
{ "aliases", new YamlSequenceNode(new YamlScalarNode("memcached")) }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = mcNet;
svc.Children[new YamlScalarNode("networks")] = networks;
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } },
{ "resources", new YamlMappingNode
{ { "limits", new YamlMappingNode { { "memory", "100M" } } } }
}
};
}
private void BuildQuickChartService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-quickchart")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.QuickChartImage);
var qcNet = new YamlMappingNode
{
{ "aliases", new YamlSequenceNode(new YamlScalarNode("quickchart")) }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = qcNet;
svc.Children[new YamlScalarNode("networks")] = networks;
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
};
}
private void BuildNewtService(YamlMappingNode services, RenderContext ctx)
{
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-newt")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(ctx.NewtImage);
var env = new YamlMappingNode
{
{ "PANGOLIN_ENDPOINT", ctx.PangolinEndpoint },
{ "NEWT_ID", ctx.NewtId ?? "CONFIGURE_ME" },
{ "NEWT_SECRET", ctx.NewtSecret ?? "CONFIGURE_ME" },
};
svc.Children[new YamlScalarNode("environment")] = env;
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = new YamlMappingNode();
svc.Children[new YamlScalarNode("networks")] = networks;
svc.Children[new YamlScalarNode("deploy")] = new YamlMappingNode
{
{ "restart_policy", new YamlMappingNode { { "condition", "any" } } }
};
}
// ── Networks ────────────────────────────────────────────────────────────
private void BuildNetworks(YamlMappingNode root, RenderContext ctx)
{
var netDef = new YamlMappingNode
{
{ "driver", "overlay" },
{ "attachable", "false" }
};
var networks = new YamlMappingNode();
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
root.Children[new YamlScalarNode("networks")] = networks;
}
// ── Volumes (CIFS) ──────────────────────────────────────────────────────
private void BuildVolumes(YamlMappingNode root, RenderContext ctx)
{
var volumes = new YamlMappingNode();
root.Children[new YamlScalarNode("volumes")] = volumes;
var volumeNames = new[]
{
$"{ctx.CustomerAbbrev}-cms-custom",
$"{ctx.CustomerAbbrev}-cms-backup",
$"{ctx.CustomerAbbrev}-cms-library",
$"{ctx.CustomerAbbrev}-cms-userscripts",
$"{ctx.CustomerAbbrev}-cms-ca-certs",
};
foreach (var volName in volumeNames)
{
if (ctx.UseCifsVolumes && !string.IsNullOrWhiteSpace(ctx.CifsServer))
{
var device = $"//{ctx.CifsServer}{ctx.CifsShareBasePath}/{volName}";
var opts = $"addr={ctx.CifsServer},username={ctx.CifsUsername},password={ctx.CifsPassword}";
if (!string.IsNullOrWhiteSpace(ctx.CifsExtraOptions))
opts += $",{ctx.CifsExtraOptions}";
var volDef = new YamlMappingNode
{
{ "driver", "local" },
{ "driver_opts", new YamlMappingNode
{
{ "type", "cifs" },
{ "device", device },
{ "o", opts }
}
}
};
volumes.Children[new YamlScalarNode(volName)] = volDef;
}
else
{
volumes.Children[new YamlScalarNode(volName)] = new YamlMappingNode();
}
}
}
// ── Secrets ─────────────────────────────────────────────────────────────
private void BuildSecrets(YamlMappingNode root, RenderContext ctx)
{
var secrets = new YamlMappingNode();
root.Children[new YamlScalarNode("secrets")] = secrets;
foreach (var secretName in ctx.SecretNames)
{
secrets.Children[new YamlScalarNode(secretName)] =
new YamlMappingNode { { "external", "true" } };
}
}
}
/// <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 CustomerAbbrev { get; set; } = string.Empty;
public string StackName { get; set; } = string.Empty;
public string CmsServerName { get; set; } = string.Empty;
public int HostHttpPort { get; set; } = 80;
// Docker images
public string CmsImage { get; set; } = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
public string MemcachedImage { get; set; } = "memcached:alpine";
public string QuickChartImage { get; set; } = "ianw/quickchart";
public string NewtImage { get; set; } = "fosrl/newt";
// Theme bind mount path on host
public string ThemeHostPath { get; set; } = string.Empty;
// MySQL (external server)
public string MySqlHost { get; set; } = string.Empty;
public string MySqlPort { get; set; } = "3306";
public string MySqlDatabase { get; set; } = "cms";
public string MySqlUser { get; set; } = "cms";
// SMTP
public string SmtpServer { get; set; } = string.Empty;
public string SmtpUsername { get; set; } = string.Empty;
public string SmtpPassword { get; set; } = string.Empty;
public string SmtpUseTls { get; set; } = "YES";
public string SmtpUseStartTls { get; set; } = "YES";
public string SmtpRewriteDomain { get; set; } = string.Empty;
public string SmtpHostname { get; set; } = string.Empty;
public string SmtpFromLineOverride { get; set; } = "NO";
// PHP settings
public string PhpPostMaxSize { get; set; } = "10G";
public string PhpUploadMaxFilesize { get; set; } = "10G";
public string PhpMaxExecutionTime { get; set; } = "600";
// Pangolin / Newt
public bool IncludeNewt { get; set; } = true;
public string PangolinEndpoint { get; set; } = "https://app.pangolin.net";
public string? NewtId { get; set; }
public string? NewtSecret { get; set; }
// CIFS volume settings
public bool UseCifsVolumes { get; set; }
public string? CifsServer { get; set; }
public string? CifsShareBasePath { get; set; }
public string? CifsUsername { get; set; }
public string? CifsPassword { get; set; }
public string? CifsExtraOptions { get; set; }
// Secrets to declare as external
public List<string> SecretNames { get; set; } = new();
// Legacy — kept for backward compat but no longer used
public string TemplateYaml { get; set; } = string.Empty;
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
public List<string> TemplateEnvLines { get; set; } = new();
public List<string> Constraints { get; set; } = new();
public string LibraryHostPath { get; set; } = string.Empty;
}

View File

@@ -1,34 +1,25 @@
using Microsoft.Extensions.Logging;
using YamlDotNet.RepresentationModel;
namespace OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Core.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)
public ValidationResult Validate(string composeYaml, string? customerAbbrev = null)
{
var errors = new List<string>();
var warnings = new List<string>();
// 1. YAML syntax
YamlStream yamlStream;
try
{
@@ -55,13 +46,11 @@ public class ComposeValidationService
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
@@ -69,17 +58,27 @@ public class ComposeValidationService
.Select(k => k.Value!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var required in RequiredServices)
// Determine required service suffixes
var requiredSuffixes = new[] { "-web", "-memcached", "-quickchart" };
var prefix = customerAbbrev ?? string.Empty;
if (!string.IsNullOrEmpty(prefix))
{
if (!presentServices.Contains(required))
errors.Add($"Missing required service: '{required}'.");
foreach (var suffix in requiredSuffixes)
{
if (!presentServices.Contains($"{prefix}{suffix}"))
errors.Add($"Missing required service: '{prefix}{suffix}'.");
}
}
else
{
// Fallback: at least check that there are web/memcached/quickchart services
if (!presentServices.Any(s => s.EndsWith("-web", StringComparison.OrdinalIgnoreCase)))
errors.Add("Missing a '-web' service.");
if (!presentServices.Any(s => s.EndsWith("-memcached", StringComparison.OrdinalIgnoreCase)))
errors.Add("Missing a '-memcached' service.");
}
// 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)
@@ -90,38 +89,28 @@ public class ComposeValidationService
}
}
// 4. Validate secrets section
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secrets)
if (HasKey(root, "secrets") && root.Children[new YamlScalarNode("secrets")] is YamlMappingNode secretsNode)
{
foreach (var (key, value) in secrets.Children)
foreach (var (key, value) in secretsNode.Children)
{
if (value is YamlMappingNode secretNode)
{
if (!HasKey(secretNode, "external"))
warnings.Add($"Secret '{((YamlScalarNode)key).Value}' is not marked as 'external: true'.");
warnings.Add($"Secret '{(key as YamlScalarNode)?.Value}' is not external.");
}
}
}
// 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));
}
=> 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();
public bool IsValid => Errors.Count == 0;
}

View File

@@ -1,11 +1,12 @@
using System.Security.Cryptography;
using System.Text;
using LibGit2Sharp;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
namespace OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Fetches template.yml and template.env from a Git repository using LibGit2Sharp.
@@ -22,10 +23,6 @@ public class GitTemplateService
_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);
@@ -79,20 +76,12 @@ public class GitTemplateService
Directory.CreateDirectory(cacheDir);
var cloneOpts = new CloneOptions
{
IsBare = false,
RecurseSubmodules = false
};
var cloneOpts = new CloneOptions { IsBare = false, RecurseSubmodules = false };
if (!string.IsNullOrEmpty(pat))
{
cloneOpts.FetchOptions.CredentialsProvider = (_, _, _) =>
new UsernamePasswordCredentials
{
Username = pat,
Password = string.Empty
};
new UsernamePasswordCredentials { Username = pat, Password = string.Empty };
}
try
@@ -118,25 +107,19 @@ public class GitTemplateService
if (!string.IsNullOrEmpty(pat))
{
fetchOpts.CredentialsProvider = (_, _, _) =>
new UsernamePasswordCredentials
{
Username = pat,
Password = string.Empty
};
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)

View File

@@ -0,0 +1,28 @@
using OTSSignsOrchestrator.Core.Models.DTOs;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
/// Implementations may use local docker CLI or SSH-based remote execution.
/// </summary>
public interface IDockerCliService
{
Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false);
Task<DeploymentResultDto> RemoveStackAsync(string stackName);
Task<List<StackInfo>> ListStacksAsync();
Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName);
}
public class StackInfo
{
public string Name { get; set; } = string.Empty;
public int ServiceCount { get; set; }
}
public class ServiceInfo
{
public string Name { get; set; } = string.Empty;
public string Image { get; set; } = string.Empty;
public string Replicas { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,19 @@
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Abstraction for Docker Swarm secret operations.
/// Implementations may use Docker.DotNet, local CLI, or SSH-based remote execution.
/// </summary>
public interface IDockerSecretsService
{
Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false);
Task<List<SecretListItem>> ListSecretsAsync();
Task<bool> DeleteSecretAsync(string name);
}
public class SecretListItem
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,536 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
/// Newinstance flow:
/// 1. Clone template repo to local cache
/// 2. Generate MySQL password → create Docker Swarm secret (never persisted locally)
/// 3. Create MySQL database + user on external MySQL server via SSH
/// 4. Render combined compose YAML (no MySQL container, CIFS volumes, Newt service)
/// 5. Deploy stack via SSH
/// </summary>
public class InstanceService
{
private readonly XiboContext _db;
private readonly GitTemplateService _git;
private readonly ComposeRenderService _compose;
private readonly ComposeValidationService _validation;
private readonly IDockerCliService _docker;
private readonly IDockerSecretsService _secrets;
private readonly XiboApiService _xibo;
private readonly SettingsService _settings;
private readonly DockerOptions _dockerOptions;
private readonly ILogger<InstanceService> _logger;
public InstanceService(
XiboContext db,
GitTemplateService git,
ComposeRenderService compose,
ComposeValidationService validation,
IDockerCliService docker,
IDockerSecretsService secrets,
XiboApiService xibo,
SettingsService settings,
IOptions<DockerOptions> dockerOptions,
ILogger<InstanceService> logger)
{
_db = db;
_git = git;
_compose = compose;
_validation = validation;
_docker = docker;
_secrets = secrets;
_xibo = xibo;
_settings = settings;
_dockerOptions = dockerOptions.Value;
_logger = logger;
}
/// <summary>
/// Creates a new CMS instance:
/// 1. Clone repo 2. Generate secrets 3. Create MySQL DB/user 4. Render compose 5. Deploy
/// </summary>
public async Task<DeploymentResultDto> CreateInstanceAsync(CreateInstanceDto dto, string? userId = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Create, userId);
var abbrev = dto.CustomerAbbrev.Trim().ToLowerInvariant();
var stackName = $"{abbrev}-cms-stack";
try
{
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
// ── Check uniqueness ────────────────────────────────────────────
var existing = await _db.CmsInstances.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
if (existing != null)
throw new InvalidOperationException($"Stack '{stackName}' already exists.");
// ── 1. Clone template repo (optional) ───────────────────────────
var repoUrl = await _settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await _settings.GetAsync(SettingsService.GitRepoPat);
if (!string.IsNullOrWhiteSpace(repoUrl))
{
_logger.LogInformation("Cloning template repo: {RepoUrl}", repoUrl);
await _git.FetchAsync(repoUrl, repoPat);
}
// ── 2. Generate MySQL password → Docker Swarm secret ────────────
var mysqlPassword = GenerateRandomPassword(32);
var mysqlSecretName = $"{abbrev}-cms-db-password";
await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
_logger.LogInformation("Docker secret created: {SecretName}", mysqlSecretName);
// ── 3. Read settings ────────────────────────────────────────────
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
var cmsServerName = (await _settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
var themePath = (await _settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
var smtpServer = await _settings.GetAsync(SettingsService.SmtpServer, string.Empty);
var smtpUsername = await _settings.GetAsync(SettingsService.SmtpUsername, string.Empty);
var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES");
var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
var cifsServer = dto.CifsServer ?? await _settings.GetAsync(SettingsService.CifsServer);
var cifsShareBasePath = dto.CifsShareBasePath ?? await _settings.GetAsync(SettingsService.CifsShareBasePath);
var cifsUsername = dto.CifsUsername ?? await _settings.GetAsync(SettingsService.CifsUsername);
var cifsPassword = dto.CifsPassword ?? await _settings.GetAsync(SettingsService.CifsPassword);
var cifsOptions = dto.CifsExtraOptions ?? await _settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// ── 4. Render compose YAML ──────────────────────────────────────
var renderCtx = new RenderContext
{
CustomerName = dto.CustomerName,
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = 80,
CmsImage = cmsImage,
MemcachedImage = memcachedImage,
QuickChartImage = quickChartImage,
NewtImage = newtImage,
ThemeHostPath = themePath,
MySqlHost = mySqlHost,
MySqlPort = mySqlPort,
MySqlDatabase = mySqlDbName,
MySqlUser = mySqlUser,
SmtpServer = smtpServer,
SmtpUsername = smtpUsername,
SmtpPassword = smtpPassword,
SmtpUseTls = smtpUseTls,
SmtpUseStartTls = smtpUseStartTls,
SmtpRewriteDomain = smtpRewriteDomain,
SmtpHostname = smtpHostname,
SmtpFromLineOverride = smtpFromLineOverride,
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint,
NewtId = dto.NewtId,
NewtSecret = dto.NewtSecret,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
SecretNames = new List<string> { mysqlSecretName },
};
var composeYaml = _compose.Render(renderCtx);
if (_dockerOptions.ValidateBeforeDeploy)
{
var validationResult = _validation.Validate(composeYaml, abbrev);
if (!validationResult.IsValid)
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
}
// ── 5. Deploy stack ─────────────────────────────────────────────
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
// ── 6. Record instance ──────────────────────────────────────────
var instance = new CmsInstance
{
CustomerName = dto.CustomerName,
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = 80,
ThemeHostPath = themePath,
LibraryHostPath = $"{abbrev}-cms-library",
SmtpServer = smtpServer,
SmtpUsername = smtpUsername,
TemplateRepoUrl = repoUrl ?? string.Empty,
TemplateRepoPat = repoPat,
Status = InstanceStatus.Active,
SshHostId = dto.SshHostId,
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
};
_db.CmsInstances.Add(instance);
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance deployed: {stackName}";
opLog.DurationMs = sw.ElapsedMilliseconds;
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogInformation("Instance created: {StackName} (id={Id}) | duration={DurationMs}ms",
stackName, instance.Id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = renderCtx.IncludeNewt ? 4 : 3;
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}", stackName);
throw;
}
}
/// <summary>
/// Creates MySQL database and user on external MySQL server via SSH.
/// Called by the ViewModel before CreateInstanceAsync since it needs SSH access.
/// </summary>
public async Task<(bool Success, string Message)> CreateMySqlDatabaseAsync(
string abbrev,
string mysqlPassword,
Func<string, Task<(int ExitCode, string Stdout, string Stderr)>> runSshCommand)
{
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
var mySqlAdminUser = await _settings.GetAsync(SettingsService.MySqlAdminUser, "root");
var mySqlAdminPassword = await _settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
var dbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var userName = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
var safePwd = mySqlAdminPassword.Replace("'", "'\\''");
var safeUserPwd = mysqlPassword.Replace("'", "'\\''");
var sql = $"CREATE DATABASE IF NOT EXISTS \\`{dbName}\\`; "
+ $"CREATE USER IF NOT EXISTS '{userName}'@'%' IDENTIFIED BY '{safeUserPwd}'; "
+ $"GRANT ALL PRIVILEGES ON \\`{dbName}\\`.* TO '{userName}'@'%'; "
+ $"FLUSH PRIVILEGES;";
var cmd = $"mysql -h {mySqlHost} -P {mySqlPort} -u {mySqlAdminUser} -p'{safePwd}' -e \"{sql}\" 2>&1";
_logger.LogInformation("Creating MySQL database {Db} and user {User}", dbName, userName);
var (exitCode, stdout, stderr) = await runSshCommand(cmd);
if (exitCode == 0)
{
_logger.LogInformation("MySQL database and user created: {Db} / {User}", dbName, userName);
return (true, $"Database '{dbName}' and user '{userName}' created.");
}
var error = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
_logger.LogError("MySQL setup failed: {Error}", error);
return (false, $"MySQL setup failed: {error.Trim()}");
}
public async Task<DeploymentResultDto> UpdateInstanceAsync(Guid id, UpdateInstanceDto dto, string? userId = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Update, userId);
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);
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;
if (dto.CifsServer != null) instance.CifsServer = dto.CifsServer;
if (dto.CifsShareBasePath != null) instance.CifsShareBasePath = dto.CifsShareBasePath;
if (dto.CifsUsername != null) instance.CifsUsername = dto.CifsUsername;
if (dto.CifsPassword != null) instance.CifsPassword = dto.CifsPassword;
if (dto.CifsExtraOptions != null) instance.CifsExtraOptions = dto.CifsExtraOptions;
var abbrev = instance.CustomerAbbrev;
var mysqlSecretName = $"{abbrev}-cms-db-password";
// Read current settings for re-render
var mySqlHost = await _settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await _settings.GetAsync(SettingsService.MySqlPort, "3306");
var mySqlDbName = (await _settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var mySqlUser = (await _settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms")).Replace("{abbrev}", abbrev);
var smtpServer = instance.SmtpServer;
var smtpUsername = instance.SmtpUsername;
var smtpPassword = await _settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
var smtpUseTls = await _settings.GetAsync(SettingsService.SmtpUseTls, "YES");
var smtpUseStartTls = await _settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
var smtpRewriteDomain = await _settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
var smtpHostname = await _settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
var smtpFromLineOverride = await _settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
var pangolinEndpoint = await _settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// Use per-instance CIFS credentials
var cifsServer = instance.CifsServer;
var cifsShareBasePath = instance.CifsShareBasePath;
var cifsUsername = instance.CifsUsername;
var cifsPassword = instance.CifsPassword;
var cifsOptions = instance.CifsExtraOptions ?? "file_mode=0777,dir_mode=0777";
var cmsImage = await _settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
var newtImage = await _settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
var memcachedImage = await _settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
var quickChartImage = await _settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
var phpPostMaxSize = await _settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
var phpUploadMaxFilesize = await _settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await _settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
var renderCtx = new RenderContext
{
CustomerName = instance.CustomerName,
CustomerAbbrev = abbrev,
StackName = instance.StackName,
CmsServerName = instance.CmsServerName,
HostHttpPort = instance.HostHttpPort,
CmsImage = cmsImage,
MemcachedImage = memcachedImage,
QuickChartImage = quickChartImage,
NewtImage = newtImage,
ThemeHostPath = instance.ThemeHostPath,
MySqlHost = mySqlHost,
MySqlPort = mySqlPort,
MySqlDatabase = mySqlDbName,
MySqlUser = mySqlUser,
SmtpServer = smtpServer,
SmtpUsername = smtpUsername,
SmtpPassword = smtpPassword,
SmtpUseTls = smtpUseTls,
SmtpUseStartTls = smtpUseStartTls,
SmtpRewriteDomain = smtpRewriteDomain,
SmtpHostname = smtpHostname,
SmtpFromLineOverride = smtpFromLineOverride,
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
IncludeNewt = true,
PangolinEndpoint = pangolinEndpoint,
UseCifsVolumes = !string.IsNullOrWhiteSpace(cifsServer),
CifsServer = cifsServer,
CifsShareBasePath = cifsShareBasePath,
CifsUsername = cifsUsername,
CifsPassword = cifsPassword,
CifsExtraOptions = cifsOptions,
SecretNames = new List<string> { mysqlSecretName },
};
var composeYaml = _compose.Render(renderCtx);
if (_dockerOptions.ValidateBeforeDeploy)
{
var validationResult = _validation.Validate(composeYaml, abbrev);
if (!validationResult.IsValid)
throw new InvalidOperationException($"Compose validation failed: {string.Join("; ", validationResult.Errors)}");
}
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();
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;
}
}
public async Task<DeploymentResultDto> DeleteInstanceAsync(
Guid id, bool retainSecrets = false, bool clearXiboCreds = true, string? userId = null)
{
var sw = Stopwatch.StartNew();
var opLog = StartOperation(OperationType.Delete, userId);
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);
var result = await _docker.RemoveStackAsync(instance.StackName);
if (!retainSecrets)
{
var mysqlSecretName = $"{instance.CustomerAbbrev}-cms-db-password";
await _secrets.DeleteSecretAsync(mysqlSecretName);
var secretMeta = await _db.SecretMetadata.FirstOrDefaultAsync(s => s.Name == mysqlSecretName);
if (secretMeta != null)
_db.SecretMetadata.Remove(secretMeta);
}
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();
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;
}
}
public async Task<CmsInstance?> GetInstanceAsync(Guid id)
=> await _db.CmsInstances.Include(i => i.SshHost).FirstOrDefaultAsync(i => i.Id == id);
public async Task<(List<CmsInstance> Items, int TotalCount)> ListInstancesAsync(
int page = 1, int pageSize = 50, string? filter = null)
{
var query = _db.CmsInstances.Include(i => i.SshHost).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);
}
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)
=> new OperationLog { Operation = type, UserId = userId, Status = OperationStatus.Pending };
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
}

View File

@@ -0,0 +1,147 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Reads and writes typed application settings from the AppSetting table.
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
/// </summary>
public class SettingsService
{
private readonly XiboContext _db;
private readonly IDataProtector _protector;
private readonly ILogger<SettingsService> _logger;
// ── Category constants ─────────────────────────────────────────────────
public const string CatGit = "Git";
public const string CatMySql = "MySql";
public const string CatSmtp = "Smtp";
public const string CatPangolin = "Pangolin";
public const string CatCifs = "Cifs";
public const string CatDefaults = "Defaults";
// ── Key constants ──────────────────────────────────────────────────────
// Git
public const string GitRepoUrl = "Git.RepoUrl";
public const string GitRepoPat = "Git.RepoPat";
// MySQL Admin
public const string MySqlHost = "MySql.Host";
public const string MySqlPort = "MySql.Port";
public const string MySqlAdminUser = "MySql.AdminUser";
public const string MySqlAdminPassword = "MySql.AdminPassword";
// SMTP
public const string SmtpServer = "Smtp.Server";
public const string SmtpPort = "Smtp.Port";
public const string SmtpUsername = "Smtp.Username";
public const string SmtpPassword = "Smtp.Password";
public const string SmtpUseTls = "Smtp.UseTls";
public const string SmtpUseStartTls = "Smtp.UseStartTls";
public const string SmtpRewriteDomain = "Smtp.RewriteDomain";
public const string SmtpHostname = "Smtp.Hostname";
public const string SmtpFromLineOverride = "Smtp.FromLineOverride";
// Pangolin
public const string PangolinEndpoint = "Pangolin.Endpoint";
// CIFS
public const string CifsServer = "Cifs.Server";
public const string CifsShareBasePath = "Cifs.ShareBasePath";
public const string CifsUsername = "Cifs.Username";
public const string CifsPassword = "Cifs.Password";
public const string CifsOptions = "Cifs.Options";
// Instance Defaults
public const string DefaultCmsImage = "Defaults.CmsImage";
public const string DefaultNewtImage = "Defaults.NewtImage";
public const string DefaultMemcachedImage = "Defaults.MemcachedImage";
public const string DefaultQuickChartImage = "Defaults.QuickChartImage";
public const string DefaultCmsServerNameTemplate = "Defaults.CmsServerNameTemplate";
public const string DefaultThemeHostPath = "Defaults.ThemeHostPath";
public const string DefaultMySqlDbTemplate = "Defaults.MySqlDbTemplate";
public const string DefaultMySqlUserTemplate = "Defaults.MySqlUserTemplate";
public const string DefaultPhpPostMaxSize = "Defaults.PhpPostMaxSize";
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
public SettingsService(
XiboContext db,
IDataProtectionProvider dataProtection,
ILogger<SettingsService> logger)
{
_db = db;
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
_logger = logger;
}
/// <summary>Get a single setting value, decrypting if sensitive.</summary>
public async Task<string?> GetAsync(string key)
{
var setting = await _db.AppSettings.FindAsync(key);
if (setting == null) return null;
return setting.IsSensitive && setting.Value != null
? Unprotect(setting.Value)
: setting.Value;
}
/// <summary>Get a setting with a fallback default.</summary>
public async Task<string> GetAsync(string key, string defaultValue)
=> await GetAsync(key) ?? defaultValue;
/// <summary>Set a single setting, encrypting if sensitive.</summary>
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
{
var setting = await _db.AppSettings.FindAsync(key);
if (setting == null)
{
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
_db.AppSettings.Add(setting);
}
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
setting.IsSensitive = isSensitive;
setting.Category = category;
setting.UpdatedAt = DateTime.UtcNow;
}
/// <summary>Save multiple settings in a single transaction.</summary>
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
{
foreach (var (key, value, category, isSensitive) in settings)
await SetAsync(key, value, category, isSensitive);
await _db.SaveChangesAsync();
_logger.LogInformation("Saved {Count} setting(s)",
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
}
/// <summary>Get all settings in a category (values decrypted).</summary>
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
{
var settings = await _db.AppSettings
.Where(s => s.Category == category)
.ToListAsync();
return settings.ToDictionary(
s => s.Key,
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
}
private string? Unprotect(string protectedValue)
{
try
{
return _protector.Unprotect(protectedValue);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
return null;
}
}
}

View File

@@ -1,15 +1,11 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Core.Configuration;
namespace OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Core.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.
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
/// </summary>
public class XiboApiService
{
@@ -27,10 +23,6 @@ public class XiboApiService
_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);
@@ -40,7 +32,6 @@ public class XiboApiService
try
{
// Xibo CMS uses OAuth2. Try resource-owner password grant first.
var baseUrl = instanceUrl.TrimEnd('/');
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
@@ -64,7 +55,6 @@ public class XiboApiService
};
}
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
instanceUrl, (int)response.StatusCode);
@@ -83,45 +73,13 @@ public class XiboApiService
}
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
};
return new XiboTestResult { IsValid = false, Message = "Connection timed out." };
}
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
};
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
}
}
// --- 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

View File

@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OTSSignsOrchestrator.Desktop.App"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
</Application.Styles>
</Application>

View File

@@ -0,0 +1,144 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Serilog;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
using OTSSignsOrchestrator.Desktop.ViewModels;
using OTSSignsOrchestrator.Desktop.Views;
namespace OTSSignsOrchestrator.Desktop;
public class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
// Apply migrations
using (var scope = Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
db.Database.Migrate();
}
Log.Information("ApplicationLifetime type: {Type}", ApplicationLifetime?.GetType().FullName ?? "null");
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Log.Information("Creating MainWindow...");
var vm = Services.GetRequiredService<MainWindowViewModel>();
Log.Information("MainWindowViewModel resolved");
var window = new MainWindow
{
DataContext = vm
};
desktop.MainWindow = window;
Log.Information("MainWindow assigned to lifetime");
window.Show();
window.Activate();
Log.Information("MainWindow Show() + Activate() called");
desktop.ShutdownRequested += (_, _) =>
{
var ssh = Services.GetService<SshConnectionService>();
ssh?.Dispose();
};
}
else
{
Log.Warning("ApplicationLifetime is NOT IClassicDesktopStyleApplicationLifetime — window cannot be shown");
}
base.OnFrameworkInitializationCompleted();
}
private static void ConfigureServices(IServiceCollection services)
{
// Configuration
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.Build();
services.AddSingleton<IConfiguration>(config);
// Options
services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
// Logging
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog(dispose: true);
});
// Data Protection
var keysDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OTSSignsOrchestrator", "keys");
Directory.CreateDirectory(keysDir);
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
.SetApplicationName("OTSSignsOrchestrator");
// Database
var connStr = config.GetConnectionString("Default") ?? "Data Source=otssigns-desktop.db";
services.AddDbContext<XiboContext>(options => options.UseSqlite(connStr));
// HTTP
services.AddHttpClient();
services.AddHttpClient("XiboApi");
// SSH services (singletons — maintain connections)
services.AddSingleton<SshConnectionService>();
// Docker services via SSH (scoped so they get fresh per-operation context)
services.AddTransient<SshDockerCliService>();
services.AddTransient<SshDockerSecretsService>();
services.AddTransient<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
services.AddTransient<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
// Core services
services.AddTransient<SettingsService>();
services.AddTransient<GitTemplateService>();
services.AddTransient<ComposeRenderService>();
services.AddTransient<ComposeValidationService>();
services.AddTransient<XiboApiService>();
services.AddTransient<InstanceService>();
// ViewModels
services.AddTransient<MainWindowViewModel>();
services.AddTransient<HostsViewModel>();
services.AddTransient<InstancesViewModel>();
services.AddTransient<CreateInstanceViewModel>();
services.AddTransient<SecretsViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddTransient<LogsViewModel>();
}
}

View File

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="SSH.NET" Version="2024.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
using Avalonia;
using Avalonia.ReactiveUI;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
namespace OTSSignsOrchestrator.Desktop;
sealed class Program
{
[STAThread]
public static void Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
.CreateLogger();
try
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
}

View File

@@ -0,0 +1,186 @@
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Models.Entities;
using Renci.SshNet;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Manages SSH connections to remote Docker Swarm hosts.
/// Creates and caches SshClient instances with key or password authentication.
/// </summary>
public class SshConnectionService : IDisposable
{
private readonly ILogger<SshConnectionService> _logger;
private readonly Dictionary<Guid, SshClient> _clients = new();
private readonly object _lock = new();
public SshConnectionService(ILogger<SshConnectionService> logger)
{
_logger = logger;
}
/// <summary>
/// Get or create a connected SshClient for a given SshHost.
/// </summary>
public SshClient GetClient(SshHost host)
{
lock (_lock)
{
if (_clients.TryGetValue(host.Id, out var existing) && existing.IsConnected)
return existing;
// Dispose old client if disconnected
if (existing != null)
{
existing.Dispose();
_clients.Remove(host.Id);
}
var client = CreateClient(host);
client.Connect();
_clients[host.Id] = client;
_logger.LogInformation("SSH connected to {Host}:{Port} as {User}", host.Host, host.Port, host.Username);
return client;
}
}
/// <summary>
/// Test the SSH connection to a host. Returns (success, message).
/// </summary>
public async Task<(bool Success, string Message)> TestConnectionAsync(SshHost host)
{
return await Task.Run(() =>
{
try
{
using var client = CreateClient(host);
client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(10);
client.Connect();
if (client.IsConnected)
{
// Quick test: run a simple command
using var cmd = client.RunCommand("docker --version");
client.Disconnect();
if (cmd.ExitStatus == 0)
return (true, $"Connected. {cmd.Result.Trim()}");
else
return (true, $"Connected but docker not available: {cmd.Error}");
}
return (false, "Failed to connect.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SSH connection test failed for {Host}:{Port}", host.Host, host.Port);
return (false, $"Connection failed: {ex.Message}");
}
});
}
/// <summary>
/// Run a command on the remote host and return (exitCode, stdout, stderr).
/// </summary>
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandAsync(SshHost host, string command)
{
return await Task.Run(() =>
{
var client = GetClient(host);
using var cmd = client.RunCommand(command);
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
});
}
/// <summary>
/// Run a command that requires stdin input (e.g., docker stack deploy --compose-file -).
/// </summary>
public async Task<(int ExitCode, string Stdout, string Stderr)> RunCommandWithStdinAsync(
SshHost host, string command, string stdinContent)
{
return await Task.Run(() =>
{
var client = GetClient(host);
// Use shell stream approach for stdin piping
// We pipe via: echo '<content>' | <command>
// But for large YAML, use a heredoc approach
var safeContent = stdinContent.Replace("'", "'\\''");
var fullCommand = $"printf '%s' '{safeContent}' | {command}";
using var cmd = client.RunCommand(fullCommand);
return (cmd.ExitStatus ?? -1, cmd.Result, cmd.Error);
});
}
/// <summary>
/// Disconnect and remove a cached client.
/// </summary>
public void Disconnect(Guid hostId)
{
lock (_lock)
{
if (_clients.TryGetValue(hostId, out var client))
{
client.Disconnect();
client.Dispose();
_clients.Remove(hostId);
_logger.LogInformation("SSH disconnected from host {HostId}", hostId);
}
}
}
private SshClient CreateClient(SshHost host)
{
var authMethods = new List<AuthenticationMethod>();
if (host.UseKeyAuth && !string.IsNullOrEmpty(host.PrivateKeyPath))
{
var keyFile = string.IsNullOrEmpty(host.KeyPassphrase)
? new PrivateKeyFile(host.PrivateKeyPath)
: new PrivateKeyFile(host.PrivateKeyPath, host.KeyPassphrase);
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, keyFile));
}
if (!string.IsNullOrEmpty(host.Password))
{
authMethods.Add(new PasswordAuthenticationMethod(host.Username, host.Password));
}
if (authMethods.Count == 0)
{
// Fall back to default SSH agent / key in ~/.ssh/
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
{
authMethods.Add(new PrivateKeyAuthenticationMethod(host.Username, new PrivateKeyFile(defaultKeyPath)));
}
else
{
throw new InvalidOperationException(
$"No authentication method configured for SSH host '{host.Label}'. " +
"Provide a private key path or password.");
}
}
var connInfo = new ConnectionInfo(host.Host, host.Port, host.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
public void Dispose()
{
lock (_lock)
{
foreach (var client in _clients.Values)
{
try { client.Disconnect(); } catch { }
client.Dispose();
}
_clients.Clear();
}
}
}

View File

@@ -0,0 +1,159 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Docker CLI service that executes docker commands on a remote host over SSH.
/// Requires an SshHost to be set before use via SetHost().
/// </summary>
public class SshDockerCliService : IDockerCliService
{
private readonly SshConnectionService _ssh;
private readonly DockerOptions _options;
private readonly ILogger<SshDockerCliService> _logger;
private SshHost? _currentHost;
public SshDockerCliService(
SshConnectionService ssh,
IOptions<DockerOptions> options,
ILogger<SshDockerCliService> logger)
{
_ssh = ssh;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Set the SSH host to use for Docker commands.
/// </summary>
public void SetHost(SshHost host)
{
_currentHost = host;
}
public SshHost? CurrentHost => _currentHost;
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
{
EnsureHost();
var sw = Stopwatch.StartNew();
var args = "docker stack deploy --compose-file -";
if (resolveImage)
args += " --resolve-image changed";
args += $" {stackName}";
_logger.LogInformation("Deploying stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandWithStdinAsync(_currentHost, args, composeYaml);
sw.Stop();
var result = new DeploymentResultDto
{
StackName = stackName,
Success = exitCode == 0,
ExitCode = exitCode,
Output = stdout,
ErrorMessage = stderr,
Message = exitCode == 0 ? "Success" : "Failed",
DurationMs = sw.ElapsedMilliseconds
};
if (result.Success)
_logger.LogInformation("Stack deployed via SSH: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
else
_logger.LogError("Stack deploy failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
return result;
}
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
{
EnsureHost();
var sw = Stopwatch.StartNew();
_logger.LogInformation("Removing stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost, $"docker stack rm {stackName}");
sw.Stop();
var result = new DeploymentResultDto
{
StackName = stackName,
Success = exitCode == 0,
ExitCode = exitCode,
Output = stdout,
ErrorMessage = stderr,
Message = exitCode == 0 ? "Success" : "Failed",
DurationMs = sw.ElapsedMilliseconds
};
if (result.Success)
_logger.LogInformation("Stack removed via SSH: {StackName}", stackName);
else
_logger.LogError("Stack remove failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
return result;
}
public async Task<List<StackInfo>> ListStacksAsync()
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, "docker stack ls --format '{{.Name}}\\t{{.Services}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<StackInfo>();
return stdout
.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();
}
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, $"docker stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<ServiceInfo>();
return stdout
.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 void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
}
}

View File

@@ -0,0 +1,140 @@
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Docker Swarm secrets management over SSH.
/// Uses docker CLI commands executed remotely instead of Docker.DotNet.
/// </summary>
public class SshDockerSecretsService : IDockerSecretsService
{
private readonly SshConnectionService _ssh;
private readonly ILogger<SshDockerSecretsService> _logger;
private SshHost? _currentHost;
public SshDockerSecretsService(SshConnectionService ssh, ILogger<SshDockerSecretsService> logger)
{
_ssh = ssh;
_logger = logger;
}
public void SetHost(SshHost host) => _currentHost = host;
public SshHost? CurrentHost => _currentHost;
public async Task<(bool Created, string SecretId)> EnsureSecretAsync(string name, string value, bool rotate = false)
{
EnsureHost();
_logger.LogInformation("Ensuring secret exists via SSH: {SecretName}", name);
// Check if secret already exists
var existing = await FindSecretAsync(name);
if (existing != null && !rotate)
{
_logger.LogDebug("Secret already exists: {SecretName} (id={SecretId})", name, existing.Value.id);
return (false, existing.Value.id);
}
if (existing != null && rotate)
{
_logger.LogInformation("Rotating secret via SSH: {SecretName} (old id={SecretId})", name, existing.Value.id);
await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
}
// Create secret via stdin
var safeValue = value.Replace("'", "'\\''");
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(
_currentHost!, $"printf '%s' '{safeValue}' | docker secret create {name} -");
if (exitCode != 0)
{
_logger.LogError("Failed to create secret via SSH: {SecretName} | error={Error}", name, stderr);
return (false, string.Empty);
}
var secretId = stdout.Trim();
_logger.LogInformation("Secret created via SSH: {SecretName} (id={SecretId})", name, secretId);
return (true, secretId);
}
public async Task<List<SecretListItem>> ListSecretsAsync()
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, "docker secret ls --format '{{.ID}}\\t{{.Name}}\\t{{.CreatedAt}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<SecretListItem>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 3);
return new SecretListItem
{
Id = parts.Length > 0 ? parts[0].Trim() : "",
Name = parts.Length > 1 ? parts[1].Trim() : "",
CreatedAt = parts.Length > 2 && DateTime.TryParse(parts[2].Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
? dt
: DateTime.MinValue
};
})
.ToList();
}
public async Task<bool> DeleteSecretAsync(string name)
{
EnsureHost();
var existing = await FindSecretAsync(name);
if (existing == null)
{
_logger.LogDebug("Secret not found for deletion: {SecretName}", name);
return true; // idempotent
}
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker secret rm {name}");
if (exitCode != 0)
{
_logger.LogError("Failed to delete secret via SSH: {SecretName} | error={Error}", name, stderr);
return false;
}
_logger.LogInformation("Secret deleted via SSH: {SecretName}", name);
return true;
}
private async Task<(string id, string name)?> FindSecretAsync(string name)
{
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, $"docker secret ls --filter 'name={name}' --format '{{{{.ID}}}}\\t{{{{.Name}}}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return null;
var line = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(l =>
{
var parts = l.Split('\t', 2);
return parts.Length > 1 && string.Equals(parts[1].Trim(), name, StringComparison.OrdinalIgnoreCase);
});
if (line == null) return null;
var p = line.Split('\t', 2);
return (p[0].Trim(), p[1].Trim());
}
private void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker secrets.");
}
}

View File

@@ -0,0 +1,244 @@
using System.Collections.ObjectModel;
using System.Security.Cryptography;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Create Instance form.
/// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials.
/// All other config comes from the Settings page.
/// </summary>
public partial class CreateInstanceViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _deployOutput = string.Empty;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string _progressStep = string.Empty;
// Core form fields — only these two are required from the user
[ObservableProperty] private string _customerName = string.Empty;
[ObservableProperty] private string _customerAbbrev = string.Empty;
// Optional Pangolin/Newt credentials (per-instance)
[ObservableProperty] private string _newtId = string.Empty;
[ObservableProperty] private string _newtSecret = string.Empty;
// CIFS / SMB credentials (per-instance, defaults loaded from global settings)
[ObservableProperty] private string _cifsServer = string.Empty;
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsExtraOptions = string.Empty;
// SSH host selection
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
// ── Derived preview properties ───────────────────────────────────────────
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
public string PreviewServiceWeb => Valid ? $"{Abbrev}-web" : "—";
public string PreviewServiceCache => Valid ? $"{Abbrev}-memcached" : "—";
public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—";
public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—";
public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—";
public string PreviewVolCustom => Valid ? $"{Abbrev}-cms-custom" : "—";
public string PreviewVolBackup => Valid ? $"{Abbrev}-cms-backup" : "—";
public string PreviewVolLibrary => Valid ? $"{Abbrev}-cms-library" : "—";
public string PreviewVolUserscripts => Valid ? $"{Abbrev}-cms-userscripts": "—";
public string PreviewVolCaCerts => Valid ? $"{Abbrev}-cms-ca-certs" : "—";
public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—";
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms" : "—";
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
private bool Valid => Abbrev.Length == 3 && System.Text.RegularExpressions.Regex.IsMatch(Abbrev, "^[a-z]{3}$");
// ─────────────────────────────────────────────────────────────────────────
public CreateInstanceViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
_ = LoadCifsDefaultsAsync();
}
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
private void RefreshPreview()
{
OnPropertyChanged(nameof(PreviewStackName));
OnPropertyChanged(nameof(PreviewServiceWeb));
OnPropertyChanged(nameof(PreviewServiceCache));
OnPropertyChanged(nameof(PreviewServiceChart));
OnPropertyChanged(nameof(PreviewServiceNewt));
OnPropertyChanged(nameof(PreviewNetwork));
OnPropertyChanged(nameof(PreviewVolCustom));
OnPropertyChanged(nameof(PreviewVolBackup));
OnPropertyChanged(nameof(PreviewVolLibrary));
OnPropertyChanged(nameof(PreviewVolUserscripts));
OnPropertyChanged(nameof(PreviewVolCaCerts));
OnPropertyChanged(nameof(PreviewSecret));
OnPropertyChanged(nameof(PreviewMySqlDb));
OnPropertyChanged(nameof(PreviewMySqlUser));
OnPropertyChanged(nameof(PreviewCmsUrl));
}
private async Task LoadHostsAsync()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
private async Task LoadCifsDefaultsAsync()
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
CifsServer = await settings.GetAsync(SettingsService.CifsServer) ?? string.Empty;
CifsShareBasePath = await settings.GetAsync(SettingsService.CifsShareBasePath) ?? string.Empty;
CifsUsername = await settings.GetAsync(SettingsService.CifsUsername) ?? string.Empty;
CifsPassword = await settings.GetAsync(SettingsService.CifsPassword) ?? string.Empty;
CifsExtraOptions = await settings.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
}
[RelayCommand]
private async Task DeployAsync()
{
// ── Validation ───────────────────────────────────────────────────
if (SelectedSshHost == null)
{
StatusMessage = "Select an SSH host first.";
return;
}
if (string.IsNullOrWhiteSpace(CustomerName))
{
StatusMessage = "Customer Name is required.";
return;
}
if (!Valid)
{
StatusMessage = "Abbreviation must be exactly 3 lowercase letters (a-z).";
return;
}
IsBusy = true;
StatusMessage = "Starting deployment...";
DeployOutput = string.Empty;
ProgressPercent = 0;
try
{
// Wire SSH host into docker services
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedSshHost);
var ssh = _services.GetRequiredService<SshConnectionService>();
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
// ── Step 1: Clone template repo ────────────────────────────────
SetProgress(10, "Cloning template repository...");
// Handled inside InstanceService.CreateInstanceAsync
// ── Step 2: Generate MySQL password → Docker secret ────────────
SetProgress(20, "Generating secrets...");
var mysqlPassword = GenerateRandomPassword(32);
// ── Step 3: Create MySQL database + user via SSH ───────────────
SetProgress(35, "Creating MySQL database and user...");
var (mysqlOk, mysqlMsg) = await instanceSvc.CreateMySqlDatabaseAsync(
Abbrev,
mysqlPassword,
cmd => ssh.RunCommandAsync(SelectedSshHost, cmd));
AppendOutput($"[MySQL] {mysqlMsg}");
if (!mysqlOk)
{
StatusMessage = mysqlMsg;
return;
}
// ── Step 4: Create Docker Swarm secret ────────────────────────
SetProgress(50, "Creating Docker Swarm secrets...");
var secretName = $"{Abbrev}-cms-db-password";
var (_, secretId) = await dockerSecrets.EnsureSecretAsync(secretName, mysqlPassword);
AppendOutput($"[Secret] {secretName} → {secretId}");
// Password is now ONLY on the Swarm — clear from memory
mysqlPassword = string.Empty;
// ── Step 5: Deploy stack ──────────────────────────────────────
SetProgress(70, "Rendering compose & deploying stack...");
var dto = new CreateInstanceDto
{
CustomerName = CustomerName.Trim(),
CustomerAbbrev = Abbrev,
SshHostId = SelectedSshHost.Id,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
CifsServer = string.IsNullOrWhiteSpace(CifsServer) ? null : CifsServer.Trim(),
CifsShareBasePath = string.IsNullOrWhiteSpace(CifsShareBasePath) ? null : CifsShareBasePath.Trim(),
CifsUsername = string.IsNullOrWhiteSpace(CifsUsername) ? null : CifsUsername.Trim(),
CifsPassword = string.IsNullOrWhiteSpace(CifsPassword) ? null : CifsPassword.Trim(),
CifsExtraOptions = string.IsNullOrWhiteSpace(CifsExtraOptions) ? null : CifsExtraOptions.Trim(),
};
var result = await instanceSvc.CreateInstanceAsync(dto);
AppendOutput(result.Output ?? string.Empty);
SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed.");
StatusMessage = result.Success
? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!"
: $"Deploy failed: {result.ErrorMessage}";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
AppendOutput(ex.ToString());
SetProgress(0, "Failed.");
}
finally
{
IsBusy = false;
}
}
private void SetProgress(double pct, string step)
{
ProgressPercent = pct;
ProgressStep = step;
AppendOutput($"[{pct:0}%] {step}");
}
private void AppendOutput(string text)
{
if (!string.IsNullOrWhiteSpace(text))
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
}
private static string GenerateRandomPassword(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
}

View File

@@ -0,0 +1,243 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for managing SSH host connections.
/// Allows adding, editing, testing, and removing remote Docker Swarm hosts.
/// </summary>
public partial class HostsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<SshHost> _hosts = new();
[ObservableProperty] private SshHost? _selectedHost;
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
// Edit form fields
[ObservableProperty] private string _editLabel = string.Empty;
[ObservableProperty] private string _editHost = string.Empty;
[ObservableProperty] private int _editPort = 22;
[ObservableProperty] private string _editUsername = string.Empty;
[ObservableProperty] private string _editPrivateKeyPath = string.Empty;
[ObservableProperty] private string _editKeyPassphrase = string.Empty;
[ObservableProperty] private string _editPassword = string.Empty;
[ObservableProperty] private bool _editUseKeyAuth = true;
private Guid? _editingHostId;
public HostsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
}
[RelayCommand]
private async Task LoadHostsAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
Hosts = new ObservableCollection<SshHost>(hosts);
StatusMessage = $"Loaded {hosts.Count} host(s).";
}
catch (Exception ex)
{
StatusMessage = $"Error loading hosts: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private void NewHost()
{
_editingHostId = null;
EditLabel = string.Empty;
EditHost = string.Empty;
EditPort = 22;
EditUsername = string.Empty;
EditPrivateKeyPath = string.Empty;
EditKeyPassphrase = string.Empty;
EditPassword = string.Empty;
EditUseKeyAuth = true;
IsEditing = true;
}
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost == null) return;
_editingHostId = SelectedHost.Id;
EditLabel = SelectedHost.Label;
EditHost = SelectedHost.Host;
EditPort = SelectedHost.Port;
EditUsername = SelectedHost.Username;
EditPrivateKeyPath = SelectedHost.PrivateKeyPath ?? string.Empty;
EditKeyPassphrase = string.Empty; // Don't show existing passphrase
EditPassword = string.Empty; // Don't show existing password
EditUseKeyAuth = SelectedHost.UseKeyAuth;
IsEditing = true;
}
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
}
[RelayCommand]
private async Task SaveHostAsync()
{
if (string.IsNullOrWhiteSpace(EditLabel) || string.IsNullOrWhiteSpace(EditHost) || string.IsNullOrWhiteSpace(EditUsername))
{
StatusMessage = "Label, Host, and Username are required.";
return;
}
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
SshHost host;
if (_editingHostId.HasValue)
{
host = await db.SshHosts.FindAsync(_editingHostId.Value)
?? throw new KeyNotFoundException("Host not found.");
host.Label = EditLabel;
host.Host = EditHost;
host.Port = EditPort;
host.Username = EditUsername;
host.PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath;
host.UseKeyAuth = EditUseKeyAuth;
host.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(EditKeyPassphrase))
host.KeyPassphrase = EditKeyPassphrase;
if (!string.IsNullOrEmpty(EditPassword))
host.Password = EditPassword;
}
else
{
host = new SshHost
{
Label = EditLabel,
Host = EditHost,
Port = EditPort,
Username = EditUsername,
PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath,
KeyPassphrase = string.IsNullOrEmpty(EditKeyPassphrase) ? null : EditKeyPassphrase,
Password = string.IsNullOrEmpty(EditPassword) ? null : EditPassword,
UseKeyAuth = EditUseKeyAuth
};
db.SshHosts.Add(host);
}
await db.SaveChangesAsync();
IsEditing = false;
StatusMessage = $"Host '{host.Label}' saved.";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error saving host: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteHostAsync()
{
if (SelectedHost == null) return;
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
if (host != null)
{
db.SshHosts.Remove(host);
await db.SaveChangesAsync();
}
// Disconnect if connected
var ssh = _services.GetRequiredService<SshConnectionService>();
ssh.Disconnect(SelectedHost.Id);
StatusMessage = $"Host '{SelectedHost.Label}' deleted.";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error deleting host: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestConnectionAsync()
{
if (SelectedHost == null) return;
IsBusy = true;
StatusMessage = $"Testing connection to {SelectedHost.Label}...";
try
{
var ssh = _services.GetRequiredService<SshConnectionService>();
var (success, message) = await ssh.TestConnectionAsync(SelectedHost);
// Update DB
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
if (host != null)
{
host.LastTestedAt = DateTime.UtcNow;
host.LastTestSuccess = success;
await db.SaveChangesAsync();
}
StatusMessage = success
? $"✓ {SelectedHost.Label}: {message}"
: $"✗ {SelectedHost.Label}: {message}";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Connection test error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,186 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for listing, viewing, and managing CMS instances.
/// </summary>
public partial class InstancesViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<CmsInstance> _instances = new();
[ObservableProperty] private CmsInstance? _selectedInstance;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _filterText = string.Empty;
[ObservableProperty] private ObservableCollection<StackInfo> _remoteStacks = new();
[ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new();
// Available SSH hosts for the dropdown
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
public InstancesViewModel(IServiceProvider services)
{
_services = services;
_ = InitAsync();
}
private async Task InitAsync()
{
await LoadHostsAsync();
await LoadInstancesAsync();
}
[RelayCommand]
private async Task LoadHostsAsync()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
[RelayCommand]
private async Task LoadInstancesAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var query = db.CmsInstances.Include(i => i.SshHost).AsQueryable();
if (!string.IsNullOrWhiteSpace(FilterText))
{
query = query.Where(i =>
i.CustomerName.Contains(FilterText) ||
i.StackName.Contains(FilterText));
}
var items = await query.OrderByDescending(i => i.CreatedAt).ToListAsync();
Instances = new ObservableCollection<CmsInstance>(items);
StatusMessage = $"Loaded {items.Count} instance(s).";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task RefreshRemoteStacksAsync()
{
if (SelectedSshHost == null)
{
StatusMessage = "Select an SSH host first.";
return;
}
IsBusy = true;
StatusMessage = $"Listing stacks on {SelectedSshHost.Label}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost);
var stacks = await dockerCli.ListStacksAsync();
RemoteStacks = new ObservableCollection<StackInfo>(stacks);
StatusMessage = $"Found {stacks.Count} stack(s) on {SelectedSshHost.Label}.";
}
catch (Exception ex)
{
StatusMessage = $"Error listing stacks: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task InspectInstanceAsync()
{
if (SelectedInstance == null) return;
if (SelectedSshHost == null && SelectedInstance.SshHost == null)
{
StatusMessage = "No SSH host associated with this instance.";
return;
}
IsBusy = true;
try
{
var host = SelectedInstance.SshHost ?? SelectedSshHost!;
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(host);
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
SelectedServices = new ObservableCollection<ServiceInfo>(services);
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
}
catch (Exception ex)
{
StatusMessage = $"Error inspecting: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteInstanceAsync()
{
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Deleting {SelectedInstance.StackName}...";
try
{
var host = SelectedInstance.SshHost ?? SelectedSshHost;
if (host == null)
{
StatusMessage = "No SSH host available for deletion.";
return;
}
// Wire up SSH-based docker services
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(host);
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
var result = await instanceSvc.DeleteInstanceAsync(SelectedInstance.Id);
StatusMessage = result.Success
? $"Instance '{SelectedInstance.StackName}' deleted."
: $"Delete failed: {result.ErrorMessage}";
await LoadInstancesAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error deleting: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for viewing operation logs.
/// </summary>
public partial class LogsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<OperationLog> _logs = new();
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private int _maxEntries = 100;
public LogsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadLogsAsync();
}
[RelayCommand]
private async Task LoadLogsAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var items = await db.OperationLogs
.Include(l => l.Instance)
.OrderByDescending(l => l.Timestamp)
.Take(MaxEntries)
.ToListAsync();
Logs = new ObservableCollection<OperationLog>(items);
StatusMessage = $"Showing {items.Count} log entries.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading logs: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
public partial class MainWindowViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty]
private ObservableObject? _currentView;
[ObservableProperty]
private string _selectedNav = "Hosts";
[ObservableProperty]
private string _statusMessage = "Ready";
public ObservableCollection<string> NavItems { get; } = new()
{
"Hosts",
"Instances",
"Create Instance",
"Secrets",
"Settings",
"Logs"
};
public MainWindowViewModel(IServiceProvider services)
{
_services = services;
NavigateTo("Hosts");
}
partial void OnSelectedNavChanged(string value)
{
NavigateTo(value);
}
[RelayCommand]
private void NavigateTo(string page)
{
CurrentView = page switch
{
"Hosts" => (ObservableObject)_services.GetService(typeof(HostsViewModel))!,
"Instances" => (ObservableObject)_services.GetService(typeof(InstancesViewModel))!,
"Create Instance" => (ObservableObject)_services.GetService(typeof(CreateInstanceViewModel))!,
"Secrets" => (ObservableObject)_services.GetService(typeof(SecretsViewModel))!,
"Settings" => (ObservableObject)_services.GetService(typeof(SettingsViewModel))!,
"Logs" => (ObservableObject)_services.GetService(typeof(LogsViewModel))!,
_ => CurrentView
};
}
public void SetStatus(string message)
{
StatusMessage = message;
}
}

View File

@@ -0,0 +1,68 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for viewing and managing Docker Swarm secrets on a remote host.
/// </summary>
public partial class SecretsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<SecretListItem> _secrets = new();
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
public SecretsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
}
private async Task LoadHostsAsync()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
[RelayCommand]
private async Task LoadSecretsAsync()
{
if (SelectedSshHost == null)
{
StatusMessage = "Select an SSH host first.";
return;
}
IsBusy = true;
try
{
var secretsSvc = _services.GetRequiredService<SshDockerSecretsService>();
secretsSvc.SetHost(SelectedSshHost);
var items = await secretsSvc.ListSecretsAsync();
Secrets = new ObservableCollection<SecretListItem>(items);
StatusMessage = $"Found {items.Count} secret(s) on {SelectedSshHost.Label}.";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,248 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, CIFS,
/// and Instance Defaults configuration, persisted via SettingsService.
/// </summary>
public partial class SettingsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
// ── Git ──────────────────────────────────────────────────────────────────
[ObservableProperty] private string _gitRepoUrl = string.Empty;
[ObservableProperty] private string _gitRepoPat = string.Empty;
// ── MySQL Admin ─────────────────────────────────────────────────────────
[ObservableProperty] private string _mySqlHost = string.Empty;
[ObservableProperty] private string _mySqlPort = "3306";
[ObservableProperty] private string _mySqlAdminUser = string.Empty;
[ObservableProperty] private string _mySqlAdminPassword = string.Empty;
// ── SMTP ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _smtpServer = string.Empty;
[ObservableProperty] private string _smtpUsername = string.Empty;
[ObservableProperty] private string _smtpPassword = string.Empty;
[ObservableProperty] private bool _smtpUseTls = true;
[ObservableProperty] private bool _smtpUseStartTls = true;
[ObservableProperty] private string _smtpRewriteDomain = string.Empty;
[ObservableProperty] private string _smtpHostname = string.Empty;
[ObservableProperty] private string _smtpFromLineOverride = "NO";
// ── Pangolin ────────────────────────────────────────────────────────────
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
// ── CIFS ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _cifsServer = string.Empty;
[ObservableProperty] private string _cifsShareBasePath = string.Empty;
[ObservableProperty] private string _cifsUsername = string.Empty;
[ObservableProperty] private string _cifsPassword = string.Empty;
[ObservableProperty] private string _cifsOptions = "file_mode=0777,dir_mode=0777";
// ── Instance Defaults ───────────────────────────────────────────────────
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
[ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms";
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
public SettingsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadAsync();
}
[RelayCommand]
private async Task LoadAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
// Git
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
GitRepoPat = await svc.GetAsync(SettingsService.GitRepoPat, string.Empty);
// MySQL
MySqlHost = await svc.GetAsync(SettingsService.MySqlHost, string.Empty);
MySqlPort = await svc.GetAsync(SettingsService.MySqlPort, "3306");
MySqlAdminUser = await svc.GetAsync(SettingsService.MySqlAdminUser, string.Empty);
MySqlAdminPassword = await svc.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
// SMTP
SmtpServer = await svc.GetAsync(SettingsService.SmtpServer, string.Empty);
SmtpUsername = await svc.GetAsync(SettingsService.SmtpUsername, string.Empty);
SmtpPassword = await svc.GetAsync(SettingsService.SmtpPassword, string.Empty);
SmtpUseTls = (await svc.GetAsync(SettingsService.SmtpUseTls, "YES")) == "YES";
SmtpUseStartTls = (await svc.GetAsync(SettingsService.SmtpUseStartTls, "YES")) == "YES";
SmtpRewriteDomain = await svc.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
SmtpHostname = await svc.GetAsync(SettingsService.SmtpHostname, string.Empty);
SmtpFromLineOverride = await svc.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
// Pangolin
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// CIFS
CifsServer = await svc.GetAsync(SettingsService.CifsServer, string.Empty);
CifsShareBasePath = await svc.GetAsync(SettingsService.CifsShareBasePath, string.Empty);
CifsUsername = await svc.GetAsync(SettingsService.CifsUsername, string.Empty);
CifsPassword = await svc.GetAsync(SettingsService.CifsPassword, string.Empty);
CifsOptions = await svc.GetAsync(SettingsService.CifsOptions, "file_mode=0777,dir_mode=0777");
// Instance Defaults
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms");
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
StatusMessage = "Settings loaded.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task SaveAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
{
// Git
(SettingsService.GitRepoUrl, NullIfEmpty(GitRepoUrl), SettingsService.CatGit, false),
(SettingsService.GitRepoPat, NullIfEmpty(GitRepoPat), SettingsService.CatGit, true),
// MySQL
(SettingsService.MySqlHost, NullIfEmpty(MySqlHost), SettingsService.CatMySql, false),
(SettingsService.MySqlPort, MySqlPort, SettingsService.CatMySql, false),
(SettingsService.MySqlAdminUser, NullIfEmpty(MySqlAdminUser), SettingsService.CatMySql, false),
(SettingsService.MySqlAdminPassword, NullIfEmpty(MySqlAdminPassword), SettingsService.CatMySql, true),
// SMTP
(SettingsService.SmtpServer, NullIfEmpty(SmtpServer), SettingsService.CatSmtp, false),
(SettingsService.SmtpUsername, NullIfEmpty(SmtpUsername), SettingsService.CatSmtp, false),
(SettingsService.SmtpPassword, NullIfEmpty(SmtpPassword), SettingsService.CatSmtp, true),
(SettingsService.SmtpUseTls, SmtpUseTls ? "YES" : "NO", SettingsService.CatSmtp, false),
(SettingsService.SmtpUseStartTls, SmtpUseStartTls ? "YES" : "NO", SettingsService.CatSmtp, false),
(SettingsService.SmtpRewriteDomain, NullIfEmpty(SmtpRewriteDomain), SettingsService.CatSmtp, false),
(SettingsService.SmtpHostname, NullIfEmpty(SmtpHostname), SettingsService.CatSmtp, false),
(SettingsService.SmtpFromLineOverride, SmtpFromLineOverride, SettingsService.CatSmtp, false),
// Pangolin
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
// CIFS
(SettingsService.CifsServer, NullIfEmpty(CifsServer), SettingsService.CatCifs, false),
(SettingsService.CifsShareBasePath, NullIfEmpty(CifsShareBasePath), SettingsService.CatCifs, false),
(SettingsService.CifsUsername, NullIfEmpty(CifsUsername), SettingsService.CatCifs, false),
(SettingsService.CifsPassword, NullIfEmpty(CifsPassword), SettingsService.CatCifs, true),
(SettingsService.CifsOptions, NullIfEmpty(CifsOptions), SettingsService.CatCifs, false),
// Instance Defaults
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultNewtImage, DefaultNewtImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultMemcachedImage, DefaultMemcachedImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultQuickChartImage, DefaultQuickChartImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultCmsServerNameTemplate, DefaultCmsServerNameTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultThemeHostPath, DefaultThemeHostPath, SettingsService.CatDefaults, false),
(SettingsService.DefaultMySqlDbTemplate, DefaultMySqlDbTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultMySqlUserTemplate, DefaultMySqlUserTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
};
await svc.SaveManyAsync(settings);
StatusMessage = "Settings saved successfully.";
}
catch (Exception ex)
{
StatusMessage = $"Error saving settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestMySqlConnectionAsync()
{
if (string.IsNullOrWhiteSpace(MySqlHost) || string.IsNullOrWhiteSpace(MySqlAdminUser))
{
StatusMessage = "MySQL Host and Admin User are required for connection test.";
return;
}
IsBusy = true;
StatusMessage = "Testing MySQL connection via SSH...";
try
{
// The test runs a mysql --version or a simple SELECT 1 query via SSH
// We need an SshHost to route through — use the first available
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OTSSignsOrchestrator.Core.Data.XiboContext>();
var host = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
.FirstOrDefaultAsync(db.SshHosts);
if (host == null)
{
StatusMessage = "No SSH hosts configured. Add one in the Hosts page first.";
return;
}
var ssh = _services.GetRequiredService<Services.SshConnectionService>();
var port = int.TryParse(MySqlPort, out var p) ? p : 3306;
var cmd = $"mysql -h {MySqlHost} -P {port} -u {MySqlAdminUser} -p'{MySqlAdminPassword.Replace("'", "'\\''")}' -e 'SELECT 1' 2>&1";
var (exitCode, stdout, stderr) = await ssh.RunCommandAsync(host, cmd);
StatusMessage = exitCode == 0
? $"MySQL connection successful via {host.Label}."
: $"MySQL connection failed: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}";
}
catch (Exception ex)
{
StatusMessage = $"MySQL test error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private static string? NullIfEmpty(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,146 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.CreateInstanceView"
x:DataType="vm:CreateInstanceViewModel">
<ScrollViewer>
<Grid ColumnDefinitions="1*,16,1*" Margin="16,12">
<!-- ══ LEFT COLUMN — inputs ══ -->
<StackPanel Grid.Column="0" Spacing="8">
<TextBlock Text="Create New Instance" FontSize="20" FontWeight="Bold" Margin="0,0,0,12" />
<!-- SSH Host -->
<TextBlock Text="Deploy to SSH Host" FontSize="12" />
<ComboBox ItemsSource="{Binding AvailableHosts}"
SelectedItem="{Binding SelectedSshHost}"
PlaceholderText="Select SSH Host..."
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Separator Margin="0,8" />
<!-- Core fields -->
<TextBlock Text="Customer Name" FontSize="12" />
<TextBox Text="{Binding CustomerName}" Watermark="e.g. Acme Corp" />
<TextBlock Text="Abbreviation (3 letters)" FontSize="12" />
<TextBox Text="{Binding CustomerAbbrev}"
Watermark="e.g. acm"
MaxLength="3" />
<Separator Margin="0,12" />
<!-- Pangolin / Newt (optional) -->
<Expander Header="Pangolin / Newt credentials (optional)">
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Text="Newt ID" FontSize="12" />
<TextBox Text="{Binding NewtId}" Watermark="(from Pangolin dashboard)" />
<TextBlock Text="Newt Secret" FontSize="12" />
<TextBox Text="{Binding NewtSecret}" PasswordChar="●" Watermark="(from Pangolin dashboard)" />
</StackPanel>
</Expander>
<!-- SMB / CIFS credentials (per-instance, defaults from global settings) -->
<Expander Header="SMB / CIFS credentials">
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Text="CIFS Server" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="e.g. 192.168.1.100" />
<TextBlock Text="Share Base Path" FontSize="12" />
<TextBox Text="{Binding CifsShareBasePath}" Watermark="e.g. /share/cms" />
<TextBlock Text="Username" FontSize="12" />
<TextBox Text="{Binding CifsUsername}" Watermark="SMB username" />
<TextBlock Text="Password" FontSize="12" />
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" Watermark="SMB password" />
<TextBlock Text="Extra Options" FontSize="12" />
<TextBox Text="{Binding CifsExtraOptions}" Watermark="file_mode=0777,dir_mode=0777" />
</StackPanel>
</Expander>
<Separator Margin="0,12" />
<!-- Deploy button + progress -->
<Button Content="Deploy Instance"
Command="{Binding DeployCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="12,8" FontWeight="SemiBold" />
<!-- Progress bar -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0"
IsVisible="{Binding IsBusy}">
<ProgressBar Value="{Binding ProgressPercent}"
Maximum="100" Height="6"
CornerRadius="3" />
<TextBlock Grid.Column="1" Text="{Binding ProgressStep}"
FontSize="11" Foreground="#a6adc8"
Margin="8,0,0,0" VerticalAlignment="Center" />
</Grid>
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="#a6adc8"
Margin="0,4,0,0" TextWrapping="Wrap" />
<!-- Deploy output -->
<TextBox Text="{Binding DeployOutput}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="260"
FontFamily="Cascadia Mono, Consolas, monospace" FontSize="11"
IsVisible="{Binding DeployOutput.Length}" />
</StackPanel>
<!-- ══ RIGHT COLUMN — live resource preview ══ -->
<Border Grid.Column="2"
Background="#1e1e2e"
CornerRadius="8"
Padding="16,14"
VerticalAlignment="Top">
<StackPanel Spacing="6">
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
Foreground="#cdd6f4" Margin="0,0,0,8" />
<TextBlock Text="Stack" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewStackName}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89b4fa" Margin="0,0,0,6" />
<TextBlock Text="Services" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewServiceWeb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceCache}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceChart}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" />
<TextBlock Text="{Binding PreviewServiceNewt}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#a6e3a1" Margin="0,0,0,6" />
<TextBlock Text="Network" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewNetwork}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#94e2d5" Margin="0,0,0,6" />
<TextBlock Text="CIFS Volumes" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewVolCustom}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolBackup}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolLibrary}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolUserscripts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" />
<TextBlock Text="{Binding PreviewVolCaCerts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#f5c2e7" Margin="0,0,0,6" />
<TextBlock Text="Docker Secret" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#fab387" Margin="0,0,0,6" />
<TextBlock Text="MySQL Database" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewMySqlDb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" />
<TextBlock Text="{Binding PreviewMySqlUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#cba6f7" Margin="0,0,0,6" />
<TextBlock Text="CMS URL" FontSize="11" Foreground="#6c7086" />
<TextBlock Text="{Binding PreviewCmsUrl}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#89dceb" />
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class CreateInstanceView : UserControl
{
public CreateInstanceView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,83 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.HostsView"
x:DataType="vm:HostsViewModel">
<DockPanel>
<!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
<Button Content="Add Host" Command="{Binding NewHostCommand}" />
<Button Content="Edit" Command="{Binding EditSelectedHostCommand}" />
<Button Content="Test Connection" Command="{Binding TestConnectionCommand}" />
<Button Content="Delete" Command="{Binding DeleteHostCommand}" />
<Button Content="Refresh" Command="{Binding LoadHostsCommand}" />
</StackPanel>
<!-- Status -->
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
FontSize="12" Foreground="#a6adc8" />
<!-- Edit panel (shown when editing) -->
<Border DockPanel.Dock="Right" Width="350" IsVisible="{Binding IsEditing}"
Background="#1e1e2e" CornerRadius="8" Padding="16" Margin="12,0,0,0">
<ScrollViewer>
<StackPanel Spacing="8">
<TextBlock Text="SSH Host" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Text="Label" FontSize="12" />
<TextBox Text="{Binding EditLabel}" Watermark="e.g. Production Swarm" />
<TextBlock Text="Host" FontSize="12" />
<TextBox Text="{Binding EditHost}" Watermark="hostname or IP" />
<TextBlock Text="Port" FontSize="12" />
<NumericUpDown Value="{Binding EditPort}" Minimum="1" Maximum="65535" />
<TextBlock Text="Username" FontSize="12" />
<TextBox Text="{Binding EditUsername}" Watermark="ssh username" />
<CheckBox Content="Use Key Authentication" IsChecked="{Binding EditUseKeyAuth}" />
<TextBlock Text="Private Key Path" FontSize="12"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBox Text="{Binding EditPrivateKeyPath}" Watermark="~/.ssh/id_rsa"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBlock Text="Key Passphrase (optional)" FontSize="12"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBox Text="{Binding EditKeyPassphrase}" PasswordChar="●"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBlock Text="Password (if not using key)" FontSize="12"
IsVisible="{Binding !EditUseKeyAuth}" />
<TextBox Text="{Binding EditPassword}" PasswordChar="●"
IsVisible="{Binding !EditUseKeyAuth}" />
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,12,0,0">
<Button Content="Save" Command="{Binding SaveHostCommand}" />
<Button Content="Cancel" Command="{Binding CancelEditCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Host list -->
<DataGrid ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Label" Binding="{Binding Label}" Width="150" />
<DataGridTextColumn Header="Host" Binding="{Binding Host}" Width="200" />
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="60" />
<DataGridTextColumn Header="User" Binding="{Binding Username}" Width="100" />
<DataGridCheckBoxColumn Header="Key Auth" Binding="{Binding UseKeyAuth}" Width="70" />
<DataGridTextColumn Header="Last Tested" Binding="{Binding LastTestedAt, StringFormat='{}{0:g}'}" Width="150" />
<DataGridCheckBoxColumn Header="OK" Binding="{Binding LastTestSuccess}" Width="50" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class HostsView : UserControl
{
public HostsView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,98 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
x:DataType="vm:InstancesViewModel">
<DockPanel>
<!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Spacing="8" Margin="0,0,0,12">
<StackPanel Orientation="Horizontal" Spacing="8">
<ComboBox ItemsSource="{Binding AvailableHosts}"
SelectedItem="{Binding SelectedSshHost}"
PlaceholderText="Select SSH Host..."
Width="250">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="List Remote Stacks" Command="{Binding RefreshRemoteStacksCommand}" />
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
<Button Content="Delete" Command="{Binding DeleteInstanceCommand}" />
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding FilterText}" Watermark="Filter by name..." Width="250" />
<Button Content="Search" Command="{Binding LoadInstancesCommand}" />
</StackPanel>
</StackPanel>
<!-- Status -->
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
FontSize="12" Foreground="#a6adc8" />
<!-- Services panel (shown when inspecting) -->
<Border DockPanel.Dock="Right" Width="350"
IsVisible="{Binding SelectedServices.Count}"
Background="#1e1e2e" CornerRadius="8" Padding="12" Margin="12,0,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Stack Services" FontWeight="SemiBold" Margin="0,0,0,8" />
<ItemsControl ItemsSource="{Binding SelectedServices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#313244" CornerRadius="4" Padding="8" Margin="0,2">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
<TextBlock Text="{Binding Image}" FontSize="11" Foreground="#a6adc8" />
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
FontSize="11" Foreground="#a6adc8" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Remote stacks panel -->
<Border DockPanel.Dock="Bottom" MaxHeight="200"
IsVisible="{Binding RemoteStacks.Count}"
Background="#1e1e2e" CornerRadius="8" Padding="12" Margin="0,8,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Remote Stacks" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding RemoteStacks}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock>
<Run Text="{Binding Name}" FontWeight="SemiBold" />
<Run Text="{Binding ServiceCount, StringFormat=' ({0} services)'}"
Foreground="#a6adc8" />
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Instance list -->
<DataGrid ItemsSource="{Binding Instances}"
SelectedItem="{Binding SelectedInstance}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="120" />
<DataGridTextColumn Header="Customer" Binding="{Binding CustomerName}" Width="120" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
<DataGridTextColumn Header="Server" Binding="{Binding CmsServerName}" Width="150" />
<DataGridTextColumn Header="Port" Binding="{Binding HostHttpPort}" Width="60" />
<DataGridTextColumn Header="Host" Binding="{Binding SshHost.Label}" Width="120" />
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="140" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class InstancesView : UserControl
{
public InstancesView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,29 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.LogsView"
x:DataType="vm:LogsViewModel">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
<Button Content="Refresh" Command="{Binding LoadLogsCommand}" />
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center"
FontSize="12" Foreground="#a6adc8" Margin="8,0,0,0" />
</StackPanel>
<DataGrid ItemsSource="{Binding Logs}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Time" Binding="{Binding Timestamp, StringFormat='{}{0:g}'}" Width="150" />
<DataGridTextColumn Header="Operation" Binding="{Binding Operation}" Width="100" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
<DataGridTextColumn Header="Instance" Binding="{Binding Instance.StackName}" Width="120" />
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="*" />
<DataGridTextColumn Header="Duration" Binding="{Binding DurationMs, StringFormat='{}{0}ms'}" Width="80" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class LogsView : UserControl
{
public LogsView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
xmlns:views="using:OTSSignsOrchestrator.Desktop.Views"
x:Class="OTSSignsOrchestrator.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="OTS Signs Orchestrator"
Width="1200" Height="800"
WindowStartupLocation="CenterScreen">
<Window.DataTemplates>
<DataTemplate DataType="vm:HostsViewModel">
<views:HostsView />
</DataTemplate>
<DataTemplate DataType="vm:InstancesViewModel">
<views:InstancesView />
</DataTemplate>
<DataTemplate DataType="vm:SecretsViewModel">
<views:SecretsView />
</DataTemplate>
<DataTemplate DataType="vm:LogsViewModel">
<views:LogsView />
</DataTemplate>
<DataTemplate DataType="vm:CreateInstanceViewModel">
<views:CreateInstanceView />
</DataTemplate>
<DataTemplate DataType="vm:SettingsViewModel">
<views:SettingsView />
</DataTemplate>
</Window.DataTemplates>
<DockPanel>
<!-- Status bar -->
<Border DockPanel.Dock="Bottom" Background="#1e1e2e" Padding="8,4">
<TextBlock Text="{Binding StatusMessage}" FontSize="12" Foreground="#a0a0a0" />
</Border>
<!-- Left nav -->
<Border DockPanel.Dock="Left" Width="180" Background="#181825" Padding="0,8">
<StackPanel Spacing="2">
<TextBlock Text="OTS Signs" FontSize="18" FontWeight="Bold" Foreground="#cdd6f4"
Margin="16,8,16,16" />
<ListBox ItemsSource="{Binding NavItems}"
SelectedItem="{Binding SelectedNav}"
Background="Transparent"
Margin="4,0">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Padding="12,8" FontSize="14" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</Border>
<!-- Main content -->
<Border Padding="16">
<ContentControl Content="{Binding CurrentView}" />
</Border>
</DockPanel>
</Window>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.SecretsView"
x:DataType="vm:SecretsViewModel">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
<ComboBox ItemsSource="{Binding AvailableHosts}"
SelectedItem="{Binding SelectedSshHost}"
PlaceholderText="Select SSH Host..."
Width="250">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Load Secrets" Command="{Binding LoadSecretsCommand}" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Margin="0,8,0,0"
FontSize="12" Foreground="#a6adc8" />
<DataGrid ItemsSource="{Binding Secrets}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="200" />
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="250" />
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="180" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class SecretsView : UserControl
{
public SecretsView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,205 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.SettingsView"
x:DataType="vm:SettingsViewModel">
<DockPanel>
<!-- Top toolbar -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,12">
<Button Content="Save All Settings"
Command="{Binding SaveCommand}"
IsEnabled="{Binding !IsBusy}"
FontWeight="SemiBold" Padding="16,8" />
<Button Content="Reload" Command="{Binding LoadCommand}" IsEnabled="{Binding !IsBusy}" />
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center"
FontSize="12" Foreground="#a6adc8" Margin="12,0,0,0" />
</StackPanel>
<!-- Scrollable settings content -->
<ScrollViewer>
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="800">
<!-- ═══ Git Repository ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="Git Repository" FontSize="16" FontWeight="SemiBold"
Foreground="#89b4fa" Margin="0,0,0,4" />
<TextBlock Text="Template repository cloned for each new instance."
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
<TextBlock Text="Repository URL" FontSize="12" />
<TextBox Text="{Binding GitRepoUrl}"
Watermark="https://github.com/org/template-repo.git" />
<TextBlock Text="Personal Access Token (PAT)" FontSize="12" />
<TextBox Text="{Binding GitRepoPat}" PasswordChar="●"
Watermark="ghp_xxxx..." />
</StackPanel>
</Border>
<!-- ═══ MySQL Connection ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="MySQL Connection" FontSize="16" FontWeight="SemiBold"
Foreground="#a6e3a1" Margin="0,0,0,4" />
<TextBlock Text="Admin credentials used to create databases and users for new instances."
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
<Grid ColumnDefinitions="3*,8,1*" RowDefinitions="Auto,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Host" FontSize="12" />
<TextBox Text="{Binding MySqlHost}" Watermark="cms-sql.otshosting.app" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Port" FontSize="12" />
<TextBox Text="{Binding MySqlPort}" Watermark="3306" />
</StackPanel>
</Grid>
<TextBlock Text="Admin Username" FontSize="12" />
<TextBox Text="{Binding MySqlAdminUser}" Watermark="root" />
<TextBlock Text="Admin Password" FontSize="12" />
<TextBox Text="{Binding MySqlAdminPassword}" PasswordChar="●" />
<Button Content="Test MySQL Connection"
Command="{Binding TestMySqlConnectionCommand}"
IsEnabled="{Binding !IsBusy}"
Margin="0,4,0,0" />
</StackPanel>
</Border>
<!-- ═══ SMTP Settings ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="SMTP Settings" FontSize="16" FontWeight="SemiBold"
Foreground="#f5c2e7" Margin="0,0,0,4" />
<TextBlock Text="Email configuration applied to all CMS instances."
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
<TextBlock Text="SMTP Server (host:port)" FontSize="12" />
<TextBox Text="{Binding SmtpServer}" Watermark="smtp.azurecomm.net:587" />
<TextBlock Text="SMTP Username" FontSize="12" />
<TextBox Text="{Binding SmtpUsername}" Watermark="user@domain.com" />
<TextBlock Text="SMTP Password" FontSize="12" />
<TextBox Text="{Binding SmtpPassword}" PasswordChar="●" />
<StackPanel Orientation="Horizontal" Spacing="16">
<CheckBox Content="Use TLS" IsChecked="{Binding SmtpUseTls}" />
<CheckBox Content="Use STARTTLS" IsChecked="{Binding SmtpUseStartTls}" />
</StackPanel>
<TextBlock Text="Rewrite Domain" FontSize="12" />
<TextBox Text="{Binding SmtpRewriteDomain}" Watermark="ots-signs.com" />
<TextBlock Text="SMTP Hostname" FontSize="12" />
<TextBox Text="{Binding SmtpHostname}" Watermark="demo.ots-signs.com" />
<TextBlock Text="From Line Override" FontSize="12" />
<TextBox Text="{Binding SmtpFromLineOverride}" Watermark="NO" />
</StackPanel>
</Border>
<!-- ═══ Pangolin / Newt ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="Pangolin (Newt Tunnel)" FontSize="16" FontWeight="SemiBold"
Foreground="#fab387" Margin="0,0,0,4" />
<TextBlock Text="Global Pangolin endpoint. Newt ID and Secret are configured per-instance."
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
<TextBlock Text="Pangolin Endpoint URL" FontSize="12" />
<TextBox Text="{Binding PangolinEndpoint}" Watermark="https://app.pangolin.net" />
</StackPanel>
</Border>
<!-- ═══ CIFS Volumes ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="CIFS Volumes" FontSize="16" FontWeight="SemiBold"
Foreground="#cba6f7" Margin="0,0,0,4" />
<TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via CIFS."
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" />
<TextBlock Text="CIFS Server (hostname/IP)" FontSize="12" />
<TextBox Text="{Binding CifsServer}" Watermark="nas.local" />
<TextBlock Text="Share Base Path" FontSize="12" />
<TextBox Text="{Binding CifsShareBasePath}" Watermark="/share/cms" />
<TextBlock Text="Username" FontSize="12" />
<TextBox Text="{Binding CifsUsername}" Watermark="smbuser" />
<TextBlock Text="Password" FontSize="12" />
<TextBox Text="{Binding CifsPassword}" PasswordChar="●" />
<TextBlock Text="Extra Mount Options" FontSize="12" />
<TextBox Text="{Binding CifsOptions}" Watermark="file_mode=0777,dir_mode=0777" />
</StackPanel>
</Border>
<!-- ═══ Instance Defaults ═══ -->
<Border Background="#1e1e2e" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock Text="Instance Defaults" FontSize="16" FontWeight="SemiBold"
Foreground="#89dceb" Margin="0,0,0,4" />
<TextBlock Text="Default Docker images, naming templates, and PHP settings for new instances. Use {abbrev} as a placeholder for the customer abbreviation."
FontSize="12" Foreground="#6c7086" Margin="0,0,0,4" TextWrapping="Wrap" />
<TextBlock Text="Docker Images" FontSize="13" FontWeight="SemiBold" Margin="0,8,0,4" />
<TextBlock Text="CMS Image" FontSize="12" />
<TextBox Text="{Binding DefaultCmsImage}"
Watermark="ghcr.io/xibosignage/xibo-cms:release-4.2.3" />
<TextBlock Text="Newt Image" FontSize="12" />
<TextBox Text="{Binding DefaultNewtImage}" Watermark="fosrl/newt" />
<TextBlock Text="Memcached Image" FontSize="12" />
<TextBox Text="{Binding DefaultMemcachedImage}" Watermark="memcached:alpine" />
<TextBlock Text="QuickChart Image" FontSize="12" />
<TextBox Text="{Binding DefaultQuickChartImage}" Watermark="ianw/quickchart" />
<TextBlock Text="Naming Templates" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />
<TextBlock Text="CMS Server Name Template" FontSize="12" />
<TextBox Text="{Binding DefaultCmsServerNameTemplate}"
Watermark="{}{abbrev}.ots-signs.com" />
<TextBlock Text="Theme Host Path Template" FontSize="12" />
<TextBox Text="{Binding DefaultThemeHostPath}"
Watermark="/cms/{abbrev}-cms-theme-custom" />
<TextBlock Text="MySQL Database Name Template" FontSize="12" />
<TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" />
<TextBlock Text="MySQL User Template" FontSize="12" />
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms" />
<TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,12,0,4" />
<Grid ColumnDefinitions="1*,8,1*,8,1*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Post Max Size" FontSize="12" />
<TextBox Text="{Binding DefaultPhpPostMaxSize}" Watermark="10G" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Upload Max Filesize" FontSize="12" />
<TextBox Text="{Binding DefaultPhpUploadMaxFilesize}" Watermark="10G" />
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Text="Max Execution Time" FontSize="12" />
<TextBox Text="{Binding DefaultPhpMaxExecutionTime}" Watermark="600" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="OTSSignsOrchestrator.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,45 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.EntityFrameworkCore": "Information"
}
},
"FileLogging": {
"Enabled": true,
"Path": "logs",
"RollingInterval": "Day",
"RetentionDays": 7
},
"Git": {
"CacheDir": ".template-cache"
},
"Docker": {
"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=otssigns-desktop.db"
},
"InstanceDefaults": {
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
"ThemeHostPath": "/cms/ots-theme",
"LibraryShareSubPath": "{abbrev}-cms-library",
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
"MySqlUserTemplate": "{abbrev}_cms",
"BaseHostHttpPort": 8080
}
}

View File

@@ -3,7 +3,9 @@ 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}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Core", "OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj", "{A1B2C3D4-1111-2222-3333-444455556666}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Desktop", "OTSSignsOrchestrator.Desktop\OTSSignsOrchestrator.Desktop.csproj", "{B2C3D4E5-5555-6666-7777-888899990000}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -14,9 +16,13 @@ Global
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
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,145 +1,376 @@
@page "/instances/create"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
@inject InstanceService InstanceSvc
@inject XiboApiService XiboApi
@inject NavigationManager Navigation
@inject IOptions<InstanceDefaultsOptions> Defaults
@using Microsoft.Extensions.Options
@using OTSSignsOrchestrator.Configuration
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
<h4 class="mb-4">New CMS Instance</h4>
<div class="row g-4">
@* ── Left column: form ── *@
<div class="col-lg-5">
<EditForm Model="model" OnValidSubmit="HandleSubmit" FormName="createInstance">
<DataAnnotationsValidator />
<ValidationSummary class="alert alert-danger py-2 small" />
<fieldset disabled="@deploying">
@* ── Customer ── *@
<div class="mb-3">
<label class="form-label fw-semibold">Customer Name</label>
<InputText @bind-Value="model.CustomerName" class="form-control"
placeholder="Acme Corporation"
@oninput="OnNameInput" />
<div class="form-text">Display name — stored as a comment in the stack file.</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Abbreviation <span class="text-muted fw-normal">(3 letters)</span></label>
<InputText @bind-Value="model.CustomerAbbrev" class="form-control font-monospace text-uppercase"
placeholder="ACM" maxlength="3"
style="text-transform:uppercase; letter-spacing:.15em; width:6rem;"
@oninput="OnAbbrevInput" />
<div class="form-text">3 letters used as a prefix for every stack resource.</div>
</div>
@* ── Optional overrides ── *@
<details class="mb-3">
<summary class="text-muted small" style="cursor:pointer;">Advanced overrides</summary>
<div class="mt-2 ps-2 border-start">
<div class="mb-2">
<label class="form-label small">Template Repo URL <span class="text-muted">(leave blank to use default)</span></label>
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control form-control-sm"
placeholder="@Defaults.Value.TemplateRepoUrl" />
</div>
<div class="mb-2">
<label class="form-label small">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
<InputText @bind-Value="constraintsText" class="form-control form-control-sm"
placeholder="node.labels.xibo==true" />
</div>
<div class="row">
<div class="col mb-2">
<label class="form-label small">Xibo Client ID</label>
<InputText @bind-Value="model.XiboUsername" class="form-control form-control-sm" />
</div>
<div class="col mb-2">
<label class="form-label small">Xibo Client Secret</label>
<InputText @bind-Value="model.XiboPassword" class="form-control form-control-sm" type="password" />
</div>
</div>
</div>
</details>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-success px-4" disabled="@deploying">
@if (deploying)
{
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
<span>Deploying…</span>
}
else
{
<span>Deploy</span>
}
</button>
<a href="/" class="btn btn-outline-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 Instance →</a>
}
</div>
}
</div>
@* ── Right column: live preview ── *@
<div class="col-lg-7">
<div class="card h-100 border-0 bg-body-tertiary">
<div class="card-header border-0 bg-body-tertiary d-flex align-items-center gap-2">
<span class="fw-semibold">Resource Preview</span>
@if (AbbrevIsValid)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">@Abbrev</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border">enter abbreviation</span>
}
</div>
<div class="card-body pt-2">
@* Stack *@
<PreviewRow Label="Stack name" Value="@Abbrev" Icon="layers" />
<PreviewRow Label="Overlay network" Value="@($"{Abbrev}-net")" Icon="share" />
<hr class="my-2" />
@* Services *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Services</div>
<PreviewRow Label="Web (Xibo CMS)" Value="@($"{Abbrev}-web")" />
<PreviewRow Label="Memcached" Value="@($"{Abbrev}-memcached")" />
<PreviewRow Label="QuickChart" Value="@($"{Abbrev}-quickchart")" />
<PreviewRow Label="Newt (tunnel)" Value="@($"{Abbrev}-newt")" Muted="true" Note="(if template provides NEWT_ID)" />
<hr class="my-2" />
@* Volumes *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">CIFS Volumes</div>
@foreach (var vol in new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" })
{
<PreviewRow Label="@vol" Value="@($"{Abbrev}-{vol}")" />
}
<PreviewRow Label="db-data" Value="@($"{Abbrev}-db-data")" Note="(local driver)" Muted="true" />
<hr class="my-2" />
@* Docker secret *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Docker Secret</div>
<PreviewRow Label="MySQL password" Value="@($"{Abbrev}_mysql_password")" Icon="key" />
<hr class="my-2" />
@* External config *@
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">External Resources</div>
<PreviewRow Label="CMS URL" Value="@CmsServer" Icon="globe" />
<PreviewRow Label="MySQL database" Value="@MySqlDb" />
<PreviewRow Label="MySQL user" Value="@MySqlUser" />
<PreviewRow Label="Theme host path" Value="@ThemePath" />
@if (!string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl))
{
<hr class="my-2" />
<div class="small text-uppercase text-muted fw-semibold mb-1" style="letter-spacing:.05em;">Template</div>
<PreviewRow Label="Repo" Value="@Defaults.Value.TemplateRepoUrl" Icon="git-branch" />
}
else
{
<div class="alert alert-warning py-1 px-2 small mt-2 mb-0">
⚠ No template repo configured. <a href="/settings">Set it in Settings</a> before deploying.
</div>
}
</div>
</div>
</div>
</div>
@* Inline sub-component for a labelled preview row *@
@code {
// ── State ────────────────────────────────────────────────────────────────
private CreateInstanceDto model = new();
private string? constraintsText;
private bool deploying;
private string? resultMessage;
private bool resultSuccess;
private Guid? createdInstanceId;
// ── Derived / preview ────────────────────────────────────────────────────
private string Abbrev =>
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
? "???"
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
private bool AbbrevIsValid =>
!string.IsNullOrWhiteSpace(model.CustomerAbbrev) && model.CustomerAbbrev.Length == 3;
private string Apply(string template) => template.Replace("{abbrev}", Abbrev);
private string CmsServer => Apply(Defaults.Value.CmsServerNameTemplate);
private string MySqlDb => Apply(Defaults.Value.MySqlDatabaseTemplate);
private string MySqlUser => Apply(Defaults.Value.MySqlUserTemplate);
private string ThemePath => string.IsNullOrWhiteSpace(model.ThemeHostPath)
? Defaults.Value.ThemeHostPath
: model.ThemeHostPath;
// ── Events ───────────────────────────────────────────────────────────────
private void OnNameInput(ChangeEventArgs e)
{
// Auto-suggest abbreviation from first word if user hasn't typed one yet
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
{
var word = (e.Value?.ToString() ?? "")
.Split([' ', '-', '_'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(w => w.Length > 0);
if (!string.IsNullOrEmpty(word))
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
}
}
private void OnAbbrevInput(ChangeEventArgs e)
{
// Enforce uppercase in the bound value immediately
model.CustomerAbbrev = (e.Value?.ToString() ?? "").ToUpperInvariant();
}
// ── Submit ───────────────────────────────────────────────────────────────
private async Task HandleSubmit()
{
deploying = true;
resultMessage = null;
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
try
{
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 in {result.DurationMs}ms ({result.ServiceCount} services)."
: $"Deployment failed: {result.ErrorMessage}";
if (result.Success)
{
var page = await InstanceSvc.ListInstancesAsync(1, 1, Abbrev);
createdInstanceId = page.Items.FirstOrDefault()?.Id;
}
}
catch (Exception ex)
{
resultSuccess = false;
resultMessage = $"Error: {ex.Message}";
}
finally
{
deploying = false;
}
}
// ── Inline sub-component: preview row ────────────────────────────────────
private RenderFragment PreviewRow(string Label, string Value, string? Icon = null, string? Note = null, bool Muted = false) =>
@<div class="d-flex align-items-baseline gap-2 mb-1 small">
<span class="text-muted" style="min-width:9rem;">@Label</span>
<code class="@(Muted ? "text-secondary" : "text-body") flex-fill" style="font-size:.875em;">@Value</code>
@if (!string.IsNullOrEmpty(Note))
{
<span class="text-muted fst-italic" style="font-size:.8em;">@Note</span>
}
</div>;
}
<PageTitle>Create Instance - OTS Signs Orchestrator</PageTitle>
<h3>Create CMS Instance</h3>
<div class="row">
<div class="col-lg-8">
<div class="col-lg-6">
<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" />
<label class="form-label fw-semibold">Customer Name</label>
<InputText @bind-Value="model.CustomerName" class="form-control"
placeholder="Acme Corporation"
@oninput="OnCustomerNameInput" />
<div class="form-text">Full display name — stored as a comment in the stack file.</div>
</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" />
<label class="form-label fw-semibold">3-Letter Abbreviation</label>
<div class="input-group">
<InputText @bind-Value="model.CustomerAbbrev" class="form-control text-uppercase"
placeholder="ACM" maxlength="3" style="text-transform:uppercase;" />
<span class="input-group-text text-muted" style="font-size:0.85em;">
Stack: <strong class="ms-1">@(model.CustomerAbbrev?.ToLowerInvariant() ?? "…")</strong>
</span>
</div>
<div class="form-text">
Exactly 3 letters (az). Used as the prefix for all stack resources:
services, volumes, and network names.
</div>
</div>
</div>
</div>
@* SMTP *@
<div class="card mb-3">
<div class="card-header">SMTP Settings</div>
<div class="card-body">
@* Show what will be auto-configured from settings *@
<div class="card mb-3 border-secondary">
<div class="card-header bg-light text-muted">Auto-configured from Settings</div>
<div class="card-body text-muted small">
<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 class="col-6 mb-1"><strong>CMS server:</strong><br />@(Defaults.Value.CmsServerNameTemplate.Replace("{abbrev}", AbbrevPreview))</div>
<div class="col-6 mb-1"><strong>Theme path:</strong><br />@Defaults.Value.ThemeHostPath</div>
<div class="col-6 mb-1"><strong>MySQL DB:</strong><br />@(Defaults.Value.MySqlDatabaseTemplate.Replace("{abbrev}", AbbrevPreview))</div>
<div class="col-6 mb-1"><strong>MySQL user:</strong><br />@(Defaults.Value.MySqlUserTemplate.Replace("{abbrev}", AbbrevPreview))</div>
<div class="col-6 mb-1"><strong>SMTP server:</strong><br />@Defaults.Value.SmtpServer</div>
<div class="col-6 mb-1"><strong>Template repo:</strong><br />@(string.IsNullOrWhiteSpace(Defaults.Value.TemplateRepoUrl) ? "⚠️ Not configured" : Defaults.Value.TemplateRepoUrl)</div>
</div>
<a href="/settings" class="small">Edit defaults in Settings</a>
</div>
</div>
@* Optional overrides *@
<div class="card mb-3">
<div class="card-header">Optional Overrides</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Template Repo URL <span class="text-muted">(overrides setting)</span></label>
<InputText @bind-Value="model.TemplateRepoUrl" class="form-control"
placeholder="Leave blank to use configured default" />
</div>
<div class="mb-3">
<label class="form-label">Theme Host Path <span class="text-muted">(overrides setting default: /cms/ots-theme)</span></label>
<InputText @bind-Value="model.ThemeHostPath" class="form-control"
placeholder="/cms/ots-theme" />
</div>
<div class="mb-3">
<label class="form-label">Placement Constraints <span class="text-muted">(comma-separated)</span></label>
<InputText @bind-Value="constraintsText" class="form-control"
placeholder="node.labels.xibo==true, node.role==manager" />
</div>
</div>
</div>
@* Git Template *@
@* Xibo Credentials (optional) *@
<div class="card mb-3">
<div class="card-header">Git Template Source</div>
<div class="card-header">Xibo API Credentials <span class="text-muted fw-normal">(optional)</span></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>
<p class="text-muted small">Provide credentials to enable API connectivity testing after deploy.</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" />
<label class="form-label">Client ID</label>
<InputText @bind-Value="model.XiboUsername" class="form-control" />
</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" />
<label class="form-label">Client Secret</label>
<InputText @bind-Value="model.XiboPassword" class="form-control" type="password" />
</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")
@(deploying ? "Deploying" : "Deploy Instance")
</button>
<a href="/" class="btn btn-secondary">Cancel</a>
</div>
@@ -160,26 +391,39 @@
</div>
@code {
private CreateInstanceDto model = new()
{
HostHttpPort = 8080
};
private CreateInstanceDto model = new();
private string? constraintsText;
private bool deploying;
private string? resultMessage;
private bool resultSuccess;
private Guid? createdInstanceId;
private XiboTestResult? xiboTestResult = null!;
/// <summary>Live abbrev preview (lowercase, 3 chars max) for the settings preview card.</summary>
private string AbbrevPreview =>
string.IsNullOrWhiteSpace(model.CustomerAbbrev)
? "???"
: model.CustomerAbbrev.ToLowerInvariant()[..Math.Min(3, model.CustomerAbbrev.Length)];
private void OnCustomerNameInput(ChangeEventArgs e)
{
// Auto-suggest abbreviation from first 3 letters of first word
if (string.IsNullOrWhiteSpace(model.CustomerAbbrev))
{
var word = (e.Value?.ToString() ?? "").Split(' ', '-', '_').FirstOrDefault(w => w.Length > 0);
if (!string.IsNullOrEmpty(word))
model.CustomerAbbrev = word[..Math.Min(3, word.Length)].ToUpperInvariant();
}
}
private async Task HandleSubmit()
{
deploying = true;
resultMessage = null;
model.CustomerAbbrev = model.CustomerAbbrev?.ToUpperInvariant() ?? string.Empty;
try
{
// Parse constraints
if (!string.IsNullOrWhiteSpace(constraintsText))
{
model.Constraints = constraintsText
@@ -198,7 +442,7 @@
if (result.Success)
{
var instance = (await InstanceSvc.ListInstancesAsync(1, 1, model.StackName)).Items.FirstOrDefault();
var instance = (await InstanceSvc.ListInstancesAsync(1, 1, model.CustomerAbbrev.ToLowerInvariant())).Items.FirstOrDefault();
createdInstanceId = instance?.Id;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,3 +53,64 @@ public class DatabaseOptions
public const string SectionName = "Database";
public string Provider { get; set; } = "Sqlite"; // Sqlite or PostgreSQL
}
/// <summary>
/// Admin-level MySQL connection used by the orchestrator to provision new customer databases.
/// Credentials are stored in app settings (encrypted at rest via Data Protection where available).
/// The generated per-customer password is NEVER stored here — it is placed directly into a Docker secret.
/// </summary>
public class MySqlAdminOptions
{
public const string SectionName = "MySqlAdmin";
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 3306;
public string AdminUser { get; set; } = "root";
public string AdminPassword { get; set; } = string.Empty;
/// <summary>If true, treat TLS/cert errors as non-fatal (useful for self-signed certs in dev).</summary>
public bool AllowInsecureTls { get; set; } = false;
}
/// <summary>
/// CIFS volume settings applied to every named Docker volume created for a new instance.
/// The credentials file on the remote host is written ephemerally via SSH and deleted immediately after
/// the docker volume create command completes.
/// </summary>
public class CifsOptions
{
public const string SectionName = "Cifs";
/// <summary>UNC-style device path, e.g. //fileserver.local/xibo-data</summary>
public string Device { get; set; } = string.Empty;
/// <summary>Hostname/IP of the CIFS server for the addr= mount option.</summary>
public string ServerAddr { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string MountOptions { get; set; } = "vers=3.0,file_mode=0660,dir_mode=0770";
}
/// <summary>
/// Defaults sourced from the Settings page, used to pre-populate or complete instance creation
/// without requiring the operator to retype them every time.
/// </summary>
public class InstanceDefaultsOptions
{
public const string SectionName = "InstanceDefaults";
/// <summary>Default Git template repo URL (operator can override per-instance).</summary>
public string TemplateRepoUrl { get; set; } = string.Empty;
public string? TemplateRepoPat { get; set; }
/// <summary>Template for CMS_SERVER_NAME. Use {abbrev} as placeholder, e.g. "{abbrev}x.ots-signs.com".</summary>
public string CmsServerNameTemplate { get; set; } = "{abbrev}.ots-signs.com";
public string SmtpServer { get; set; } = string.Empty;
public string SmtpUsername { get; set; } = string.Empty;
public string SmtpPassword { get; set; } = string.Empty;
/// <summary>Base host HTTP port; each new instance auto-increments from this value.</summary>
public int BaseHostHttpPort { get; set; } = 8080;
/// <summary>Template for the theme host path. Use {abbrev} as placeholder.</summary>
/// <summary>Static host path for the theme volume mount. Overridable per-instance.</summary>
public string ThemeHostPath { get; set; } = "/cms/ots-theme";
/// <summary>Template for the library CIFS volume sub-path. Use {abbrev} as placeholder.</summary>
public string LibraryShareSubPath { get; set; } = "{abbrev}-cms-library";
/// <summary>MySQL database name template. Use {abbrev}.</summary>
public string MySqlDatabaseTemplate { get; set; } = "{abbrev}_cms_db";
/// <summary>MySQL username template. Use {abbrev}.</summary>
public string MySqlUserTemplate { get; set; } = "{abbrev}_cms";
}

View File

@@ -23,6 +23,9 @@ public static class DependencyInjection
builder.Services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
builder.Services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
builder.Services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
builder.Services.Configure<MySqlAdminOptions>(config.GetSection(MySqlAdminOptions.SectionName));
builder.Services.Configure<CifsOptions>(config.GetSection(CifsOptions.SectionName));
builder.Services.Configure<InstanceDefaultsOptions>(config.GetSection(InstanceDefaultsOptions.SectionName));
// --- Serilog ---
ConfigureSerilog(builder);
@@ -66,6 +69,7 @@ public static class DependencyInjection
builder.Services.AddScoped<ComposeValidationService>();
builder.Services.AddScoped<DockerCliService>();
builder.Services.AddScoped<DockerSecretsService>();
builder.Services.AddScoped<MySqlProvisionService>();
builder.Services.AddScoped<XiboApiService>();
builder.Services.AddScoped<InstanceService>();
builder.Services.AddScoped<OidcProviderService>();

View File

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

View File

@@ -4,39 +4,27 @@ namespace OTSSignsOrchestrator.Models.DTOs;
public class CreateInstanceDto
{
/// <summary>Full display name of the customer (stored as YAML comment).</summary>
[Required, MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string StackName { get; set; } = string.Empty;
/// <summary>3-letter uppercase abbreviation used as prefix in all stack resource names.</summary>
[Required, StringLength(3, MinimumLength = 3, ErrorMessage = "Abbreviation must be exactly 3 letters.")]
[RegularExpression("^[a-zA-Z]{3}$", ErrorMessage = "Abbreviation must be 3 letters (a-z, A-Z).")]
public string CustomerAbbrev { 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;
// TemplateRepoUrl and TemplateRepoPat are sourced from app settings (Settings page) and
// optionally overridden here per-instance.
[MaxLength(500)]
public string? TemplateRepoUrl { get; set; }
[MaxLength(500)]
public string? TemplateRepoPat { get; set; }
/// <summary>Override the theme host path from settings (e.g. /cms/ots-theme).</summary>
[MaxLength(500)]
public string? ThemeHostPath { get; set; }
/// <summary>Comma-separated placement constraints.</summary>
public List<string>? Constraints { get; set; }

View File

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

View File

@@ -26,6 +26,10 @@ public class CmsInstance
[Required, MaxLength(100)]
public string CustomerName { get; set; } = string.Empty;
/// <summary>3-letter lowercase abbreviation used as stack resource prefix (e.g. "ots").</summary>
[Required, MaxLength(3)]
public string CustomerAbbrev { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string StackName { get; set; } = string.Empty;

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,22 +9,27 @@ 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.
/// Renders a single self-contained Compose v3.9 YAML from the template and user/settings inputs.
/// The output file has no separate env_file — all environment variables are inlined.
/// Service and volume names are prefixed with the 3-letter customer abbreviation.
/// CIFS-backed named volumes are rendered with driver_opts so they can be created on the swarm.
/// </summary>
public class ComposeRenderService
{
private readonly XiboOptions _xiboOptions;
private readonly DockerOptions _dockerOptions;
private readonly CifsOptions _cifsOptions;
private readonly ILogger<ComposeRenderService> _logger;
public ComposeRenderService(
IOptions<XiboOptions> xiboOptions,
IOptions<DockerOptions> dockerOptions,
IOptions<CifsOptions> cifsOptions,
ILogger<ComposeRenderService> logger)
{
_xiboOptions = xiboOptions.Value;
_dockerOptions = dockerOptions.Value;
_cifsOptions = cifsOptions.Value;
_logger = logger;
}
@@ -33,7 +38,7 @@ public class ComposeRenderService
/// </summary>
public string Render(RenderContext ctx)
{
_logger.LogInformation("Rendering Compose for stack: {StackName}", ctx.StackName);
_logger.LogInformation("Rendering Compose for stack: {StackName} (abbrev={Abbrev})", ctx.StackName, ctx.CustomerAbbrev);
// Parse template YAML
var yaml = new YamlStream();
@@ -45,7 +50,7 @@ public class ComposeRenderService
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
// Ensure version
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.8");
root.Children[new YamlScalarNode("version")] = new YamlScalarNode("3.9");
// Process services
EnsureServices(root, ctx);
@@ -57,17 +62,16 @@ public class ComposeRenderService
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
// Clean up YAML stream terminators
output = output.Replace("...\n", "").Replace("...", "");
// Prepend customer comment
output = $"# Customer: {ctx.CustomerName}\n" + output;
_logger.LogDebug("Compose rendered: {ServiceCount} services, {SecretCount} secrets",
GetServiceCount(root), ctx.SecretNames.Count);
@@ -81,257 +85,265 @@ public class ComposeRenderService
var services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
// CMS Database (MySQL)
EnsureCmsDb(services, ctx);
// Clear any services from template — we always build them deterministically
services.Children.Clear();
// 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);
EnsureNewt(services, 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;
}
var a = ctx.CustomerAbbrev;
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-web")] = 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");
// Build environment — merge template.env first, then apply our required overrides
var env = BuildEnvFromTemplate(ctx.TemplateEnvValues, ctx);
svc.Children[new YamlScalarNode("environment")] = env;
// Ports
var ports = new YamlSequenceNode(
svc.Children[new YamlScalarNode("ports")] = new YamlSequenceNode(
new YamlScalarNode($"{ctx.HostHttpPort}:80")
);
svc.Children[new YamlScalarNode("ports")] = ports;
// Volumes (bind mounts + named volumes)
var volumes = new YamlSequenceNode(
// Named volumes (CIFS-backed) + theme bind mount
svc.Children[new YamlScalarNode("volumes")] = new YamlSequenceNode(
new YamlScalarNode($"{a}-cms-custom:/var/www/cms/custom"),
new YamlScalarNode($"{a}-cms-backup:/var/www/backup"),
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")
new YamlScalarNode($"{a}-cms-library:/var/www/cms/library"),
new YamlScalarNode($"{a}-cms-userscripts:/var/www/cms/web/userscripts"),
new YamlScalarNode($"{a}-cms-ca-certs:/var/www/cms/ca-certs")
);
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")
svc.Children[new YamlScalarNode("secrets")] = new YamlSequenceNode(
ctx.SecretNames.Select(n => (YamlNode)new YamlScalarNode(n)).ToList()
);
ApplyConstraints(svc, ctx);
// Network
var netNode = new YamlMappingNode();
var aliasNode = new YamlMappingNode();
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("web"));
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
svc.Children[new YamlScalarNode("networks")] = netNode;
// Deploy
var deploy = BuildDeploy(ctx, memoryLimit: "1G");
svc.Children[new YamlScalarNode("deploy")] = deploy;
}
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;
}
var a = ctx.CustomerAbbrev;
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-memcached")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.Memcached);
svc.Children[new YamlScalarNode("command")] = new YamlSequenceNode(
new YamlScalarNode("memcached"), new YamlScalarNode("-m"), new YamlScalarNode("15")
);
ApplyConstraints(svc, ctx);
var netNode = new YamlMappingNode();
var aliasNode = new YamlMappingNode();
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("memcached"));
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
svc.Children[new YamlScalarNode("networks")] = netNode;
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx, memoryLimit: "100M");
}
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;
}
var a = ctx.CustomerAbbrev;
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-quickchart")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode(_xiboOptions.DefaultImages.QuickChart);
ApplyConstraints(svc, ctx);
var netNode = new YamlMappingNode();
var aliasNode = new YamlMappingNode();
aliasNode.Children[new YamlScalarNode("aliases")] = new YamlSequenceNode(new YamlScalarNode("quickchart"));
netNode.Children[new YamlScalarNode($"{a}-net")] = aliasNode;
svc.Children[new YamlScalarNode("networks")] = netNode;
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
}
private void ApplyConstraints(YamlMappingNode service, RenderContext ctx)
private void EnsureNewt(YamlMappingNode services, RenderContext ctx)
{
if (ctx.Constraints == null || ctx.Constraints.Count == 0)
var a = ctx.CustomerAbbrev;
// Only add newt if the template env provides the newt config
if (!ctx.TemplateEnvValues.ContainsKey("NEWT_ID") && !ctx.TemplateEnvValues.ContainsKey("PANGOLIN_ENDPOINT"))
return;
var deployKey = new YamlScalarNode("deploy");
YamlMappingNode deploy;
if (service.Children.ContainsKey(deployKey))
deploy = (YamlMappingNode)service.Children[deployKey];
else
var svc = new YamlMappingNode();
services.Children[new YamlScalarNode($"{a}-newt")] = svc;
svc.Children[new YamlScalarNode("image")] = new YamlScalarNode("fosrl/newt");
var env = new YamlMappingNode();
if (ctx.TemplateEnvValues.TryGetValue("PANGOLIN_ENDPOINT", out var endpoint))
env.Children[new YamlScalarNode("PANGOLIN_ENDPOINT")] = new YamlScalarNode(endpoint);
if (ctx.TemplateEnvValues.TryGetValue("NEWT_ID", out var newtId))
env.Children[new YamlScalarNode("NEWT_ID")] = new YamlScalarNode(newtId);
if (ctx.TemplateEnvValues.TryGetValue("NEWT_SECRET", out var newtSecret))
env.Children[new YamlScalarNode("NEWT_SECRET")] = new YamlScalarNode(newtSecret);
svc.Children[new YamlScalarNode("environment")] = env;
var netNode = new YamlMappingNode();
netNode.Children[new YamlScalarNode($"{a}-net")] = new YamlMappingNode();
svc.Children[new YamlScalarNode("networks")] = netNode;
svc.Children[new YamlScalarNode("deploy")] = BuildDeploy(ctx);
}
/// <summary>
/// Build the environment mapping for cms-web: start with template.env values,
/// then apply all required orchestrator overrides.
/// </summary>
private YamlMappingNode BuildEnvFromTemplate(Dictionary<string, string> templateEnv, RenderContext ctx)
{
var env = new YamlMappingNode();
// Apply template values first
foreach (var (k, v) in templateEnv)
env.Children[new YamlScalarNode(k)] = new YamlScalarNode(v);
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(ctx.CustomerAbbrev);
// Required overrides — these always win over template values
env.Children[new YamlScalarNode("CMS_USE_MEMCACHED")] = new YamlScalarNode("true");
env.Children[new YamlScalarNode("MEMCACHED_HOST")] = new YamlScalarNode("memcached");
env.Children[new YamlScalarNode("CMS_SERVER_NAME")] = new YamlScalarNode(ctx.CmsServerName);
env.Children[new YamlScalarNode("MYSQL_HOST")] = new YamlScalarNode(ctx.MySqlHost);
env.Children[new YamlScalarNode("MYSQL_PORT")] = new YamlScalarNode(ctx.MySqlPort.ToString());
env.Children[new YamlScalarNode("MYSQL_DATABASE")] = new YamlScalarNode(ctx.MySqlDatabase);
env.Children[new YamlScalarNode("MYSQL_USER")] = new YamlScalarNode(ctx.MySqlUser);
env.Children[new YamlScalarNode("MYSQL_PASSWORD")] = new YamlScalarNode($"/run/secrets/{mysqlSecretName}");
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")] = new YamlScalarNode(ctx.SmtpPassword);
env.Children[new YamlScalarNode("CMS_SMTP_USE_TLS")] = new YamlScalarNode("YES");
env.Children[new YamlScalarNode("CMS_SMTP_USE_STARTTLS")] = new YamlScalarNode("YES");
env.Children[new YamlScalarNode("CMS_SMTP_REWRITE_DOMAIN")] = new YamlScalarNode(ctx.SmtpRewriteDomain);
env.Children[new YamlScalarNode("CMS_SMTP_FROM_LINE_OVERRIDE")] = new YamlScalarNode("NO");
env.Children[new YamlScalarNode("CMS_PHP_POST_MAX_SIZE")] = new YamlScalarNode("10G");
env.Children[new YamlScalarNode("CMS_PHP_UPLOAD_MAX_FILESIZE")] = new YamlScalarNode("10G");
env.Children[new YamlScalarNode("CMS_PHP_MAX_EXECUTION_TIME")] = new YamlScalarNode("600");
return env;
}
private static YamlMappingNode BuildDeploy(RenderContext ctx, string? memoryLimit = null)
{
var deploy = new YamlMappingNode();
var restartPolicy = new YamlMappingNode();
restartPolicy.Children[new YamlScalarNode("condition")] = new YamlScalarNode("any");
deploy.Children[new YamlScalarNode("restart_policy")] = restartPolicy;
if (memoryLimit != null)
{
deploy = new YamlMappingNode();
service.Children[deployKey] = deploy;
var resources = new YamlMappingNode();
var limits = new YamlMappingNode();
limits.Children[new YamlScalarNode("memory")] = new YamlScalarNode(memoryLimit);
resources.Children[new YamlScalarNode("limits")] = limits;
deploy.Children[new YamlScalarNode("resources")] = resources;
}
var placementKey = new YamlScalarNode("placement");
YamlMappingNode placement;
if (deploy.Children.ContainsKey(placementKey))
placement = (YamlMappingNode)deploy.Children[placementKey];
else
if (ctx.Constraints != null && ctx.Constraints.Count > 0)
{
placement = new YamlMappingNode();
deploy.Children[placementKey] = placement;
var placement = new YamlMappingNode();
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(
ctx.Constraints.Select(c => (YamlNode)new YamlScalarNode(c)).ToList()
);
deploy.Children[new YamlScalarNode("placement")] = placement;
}
var constraintNodes = ctx.Constraints
.Select(c => (YamlNode)new YamlScalarNode(c))
.ToList();
placement.Children[new YamlScalarNode("constraints")] = new YamlSequenceNode(constraintNodes);
return deploy;
}
private void EnsureVolumes(YamlMappingNode root, RenderContext ctx)
{
var a = ctx.CustomerAbbrev;
var volumesKey = new YamlScalarNode("volumes");
YamlMappingNode volumes;
var volumes = new YamlMappingNode();
root.Children[volumesKey] = volumes;
if (root.Children.ContainsKey(volumesKey))
volumes = (YamlMappingNode)root.Children[volumesKey];
else
// CIFS-backed named volumes
var cifsVolumes = new[] { "cms-custom", "cms-backup", "cms-library", "cms-userscripts", "cms-ca-certs" };
foreach (var vol in cifsVolumes)
{
volumes = new YamlMappingNode();
root.Children[volumesKey] = volumes;
var volName = $"{a}-{vol}";
volumes.Children[new YamlScalarNode(volName)] = BuildCifsVolumeNode(vol, ctx);
}
var prefix = AppConstants.SanitizeName(ctx.CustomerName);
// Plain local volume for DB (not CIFS — stays on the node)
volumes.Children[new YamlScalarNode($"{a}-db-data")] = new YamlMappingNode();
}
// 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();
/// <summary>
/// Build a CIFS-driver volume node matching the pattern:
/// <code>
/// driver: local
/// driver_opts:
/// type: cifs
/// device: "//fileserver.local/share/subpath"
/// o: "addr=fileserver.local,credentials=/etc/docker-cifs-credentials,file_mode=0660,dir_mode=0770,vers=3.0"
/// </code>
/// The credentials= path points to a credentials file pre-deployed on the target host.
/// </summary>
private YamlMappingNode BuildCifsVolumeNode(string subPath, RenderContext ctx)
{
var node = new YamlMappingNode();
node.Children[new YamlScalarNode("driver")] = new YamlScalarNode("local");
var opts = new YamlMappingNode();
opts.Children[new YamlScalarNode("type")] = new YamlScalarNode("cifs");
// Append the sub-path derived from the abbreviation to the base CIFS device
var device = _cifsOptions.Device.TrimEnd('/') + "/" + ctx.CustomerAbbrev + "-" + subPath;
opts.Children[new YamlScalarNode("device")] = new YamlScalarNode(device);
// The credentials file path on the remote host — written ephemerally during provisioning
var oValue = $"addr={_cifsOptions.ServerAddr},credentials={ctx.CifsCredentialsFilePath},{_cifsOptions.MountOptions}";
opts.Children[new YamlScalarNode("o")] = new YamlScalarNode(oValue);
node.Children[new YamlScalarNode("driver_opts")] = opts;
return node;
}
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;
}
var secrets = new YamlMappingNode();
root.Children[secretsKey] = secrets;
foreach (var secretName in ctx.SecretNames)
{
var secretNode = new YamlMappingNode
secrets.Children[new YamlScalarNode(secretName)] = new YamlMappingNode
{
{ "external", "true" }
};
secrets.Children[new YamlScalarNode(secretName)] = secretNode;
}
}
private void EnsureNetworks(YamlMappingNode root, RenderContext ctx)
{
var networksKey = new YamlScalarNode("networks");
var networks = new YamlMappingNode();
root.Children[networksKey] = networks;
var netDef = new YamlMappingNode();
netDef.Children[new YamlScalarNode("driver")] = new YamlScalarNode("overlay");
netDef.Children[new YamlScalarNode("attachable")] = new YamlScalarNode("false");
networks.Children[new YamlScalarNode($"{ctx.CustomerAbbrev}-net")] = netDef;
}
private static int GetServiceCount(YamlMappingNode root)
{
var servicesKey = new YamlScalarNode("services");
@@ -342,22 +354,41 @@ public class ComposeRenderService
}
/// <summary>
/// Context object with all inputs needed to render a Compose file.
/// All inputs needed to render a single Compose file.
/// </summary>
public class RenderContext
{
public string CustomerName { get; set; } = string.Empty;
/// <summary>3-letter abbreviation used as naming prefix (e.g. "ots").</summary>
public string CustomerAbbrev { 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;
// MySQL coordinates (external server — NOT a sidecar container)
public string MySqlHost { get; set; } = string.Empty;
public int MySqlPort { get; set; } = 3306;
public string MySqlDatabase { get; set; } = string.Empty;
public string MySqlUser { get; set; } = string.Empty;
// SMTP
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();
public string SmtpPassword { get; set; } = string.Empty;
public string SmtpRewriteDomain { get; set; } = string.Empty;
/// <summary>
/// Path on the remote target host where the CIFS credentials file will be placed ephemerally during provisioning.
/// Written as the credentials= value in volume driver_opts.
/// </summary>
public string CifsCredentialsFilePath { get; set; } = "/etc/docker-cifs-credentials";
public string TemplateYaml { get; set; } = string.Empty;
/// <summary>Parsed key/value pairs from template.env (placeholder-substituted).</summary>
public Dictionary<string, string> TemplateEnvValues { get; set; } = new();
public List<string> Constraints { get; set; } = new();
/// <summary>Secret names to declare as external in the compose file.</summary>
public List<string> SecretNames { get; set; } = new();
}

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Orchestrator service for CMS instance lifecycle (Create, Update, Delete, List, Inspect).
/// Coordinates GitTemplateService, ComposeRenderService, ComposeValidationService,
/// DockerCliService, DockerSecretsService, and XiboApiService.
/// DockerCliService, DockerSecretsService, MySqlProvisionService, and XiboApiService.
/// </summary>
public class InstanceService
{
@@ -23,8 +23,12 @@ public class InstanceService
private readonly ComposeValidationService _validation;
private readonly DockerCliService _docker;
private readonly DockerSecretsService _secrets;
private readonly MySqlProvisionService _mysql;
private readonly XiboApiService _xibo;
private readonly DockerOptions _dockerOptions;
private readonly MySqlAdminOptions _mysqlAdminOptions;
private readonly CifsOptions _cifsOptions;
private readonly InstanceDefaultsOptions _instanceDefaults;
private readonly ILogger<InstanceService> _logger;
public InstanceService(
@@ -34,8 +38,12 @@ public class InstanceService
ComposeValidationService validation,
DockerCliService docker,
DockerSecretsService secrets,
MySqlProvisionService mysql,
XiboApiService xibo,
IOptions<DockerOptions> dockerOptions,
IOptions<MySqlAdminOptions> mysqlAdminOptions,
IOptions<CifsOptions> cifsOptions,
IOptions<InstanceDefaultsOptions> instanceDefaults,
ILogger<InstanceService> logger)
{
_db = db;
@@ -44,67 +52,129 @@ public class InstanceService
_validation = validation;
_docker = docker;
_secrets = secrets;
_mysql = mysql;
_xibo = xibo;
_dockerOptions = dockerOptions.Value;
_mysqlAdminOptions = mysqlAdminOptions.Value;
_cifsOptions = cifsOptions.Value;
_instanceDefaults = instanceDefaults.Value;
_logger = logger;
}
/// <summary>
/// Create and deploy a new CMS instance.
/// Steps:
/// 1. Validate abbreviation and uniqueness
/// 2. Fetch templates from Git (configured repo)
/// 3. Generate secrets in memory, create on Swarm — never persisted
/// 4. Provision MySQL DB + user via direct connection
/// 5. Render merged single-file Compose YAML
/// 6. Validate Compose
/// 7. Deploy stack
/// 8. Persist instance metadata (no secret values stored)
/// </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);
// Resolve abbreviation to lowercase for naming, uppercase for display
var abbrev = dto.CustomerAbbrev.ToLowerInvariant();
// Derive stack name and resource names from abbreviation
var stackName = abbrev;
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(abbrev);
// Resolve per-instance settings (use DTO override or fall back to global defaults)
var templateRepoUrl = !string.IsNullOrWhiteSpace(dto.TemplateRepoUrl)
? dto.TemplateRepoUrl
: _instanceDefaults.TemplateRepoUrl;
var templateRepoPat = dto.TemplateRepoPat ?? _instanceDefaults.TemplateRepoPat;
var cmsServerName = _instanceDefaults.CmsServerNameTemplate.Replace("{abbrev}", abbrev);
var themeHostPath = !string.IsNullOrWhiteSpace(dto.ThemeHostPath)
? dto.ThemeHostPath
: _instanceDefaults.ThemeHostPath;
var mysqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev);
var mysqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev);
var smtpRewriteDomain = ExtractDomain(_instanceDefaults.SmtpUsername);
try
{
_logger.LogInformation("Creating instance: stack={StackName}, customer={Customer}", dto.StackName, dto.CustomerName);
// ----------------------------------------------------------------
// Step 1 — Validate no duplicate stack/abbreviation
// ----------------------------------------------------------------
_logger.LogInformation("Creating instance: abbrev={Abbrev}, customer={Customer}", abbrev, dto.CustomerName);
// 1. Validate no duplicate stack name
var existing = await _db.CmsInstances.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.StackName == dto.StackName && i.DeletedAt == null);
.FirstOrDefaultAsync(i => i.StackName == stackName && i.DeletedAt == null);
if (existing != null)
throw new InvalidOperationException($"Stack '{dto.StackName}' already exists.");
throw new InvalidOperationException($"An instance with abbreviation '{dto.CustomerAbbrev}' (stack '{stackName}') already exists.");
// 2. Fetch templates from Git
var template = await _git.FetchAsync(dto.TemplateRepoUrl, dto.TemplateRepoPat);
// ----------------------------------------------------------------
// Step 2 — Clone / fetch templates from configured Git repo
// ----------------------------------------------------------------
if (string.IsNullOrWhiteSpace(templateRepoUrl))
throw new InvalidOperationException("No template repo URL configured. Set InstanceDefaults:TemplateRepoUrl in settings.");
// 3. Create Docker secrets
var smtpResult = await _secrets.EnsureSecretAsync(AppConstants.GlobalSmtpSecretName, dto.SmtpPassword);
var template = await _git.FetchAsync(templateRepoUrl, templateRepoPat);
// Parse template.env into key/value dictionary; apply abbreviation placeholder substitution
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
// ----------------------------------------------------------------
// Step 3 — Generate secrets in memory; create on Swarm via Docker
// NEVER store generated password values in DB or disk
// ----------------------------------------------------------------
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);
var mysqlSecretResult = await _secrets.EnsureSecretAsync(mysqlSecretName, mysqlPassword);
_logger.LogInformation("MySQL secret '{SecretName}' — created={Created}", mysqlSecretName, mysqlSecretResult.Created);
// Track secret metadata in DB (name + customer, NOT the value)
await EnsureSecretMetadata(mysqlSecretName, false, dto.CustomerName);
// 4. Build render context
var constraints = dto.Constraints ?? new List<string>();
if (constraints.Count == 0)
constraints = _dockerOptions.DefaultConstraints;
// ----------------------------------------------------------------
// Step 4 — Provision MySQL database and user
// ----------------------------------------------------------------
await _mysql.ProvisionAsync(mysqlDatabase, mysqlUser, mysqlPassword);
// mysqlPassword goes out of scope after this method — it is not stored anywhere
// ----------------------------------------------------------------
// Step 5 — Build render context and render single Compose YAML
// ----------------------------------------------------------------
var constraints = (dto.Constraints is { Count: > 0 }) ? dto.Constraints : _dockerOptions.DefaultConstraints;
var hostHttpPort = _instanceDefaults.BaseHostHttpPort; // TODO: auto-increment per instance
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,
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = hostHttpPort,
ThemeHostPath = themeHostPath,
MySqlHost = _mysqlAdminOptions.Host,
MySqlPort = _mysqlAdminOptions.Port,
MySqlDatabase = mysqlDatabase,
MySqlUser = mysqlUser,
SmtpServer = _instanceDefaults.SmtpServer,
SmtpUsername = _instanceDefaults.SmtpUsername,
SmtpPassword = _instanceDefaults.SmtpPassword,
SmtpRewriteDomain = smtpRewriteDomain,
TemplateYaml = template.Yaml,
TemplateEnvLines = template.EnvLines,
TemplateEnvValues = templateEnvValues,
Constraints = constraints,
SecretNames = new List<string> { AppConstants.GlobalSmtpSecretName, mysqlSecretName }
SecretNames = new List<string> { mysqlSecretName },
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
};
// 5. Render Compose YAML
var composeYaml = _compose.Render(renderCtx);
// 6. Validate Compose
// ----------------------------------------------------------------
// Step 6 — Validate Compose
// ----------------------------------------------------------------
if (_dockerOptions.ValidateBeforeDeploy)
{
var validationResult = _validation.Validate(composeYaml);
@@ -115,25 +185,30 @@ public class InstanceService
}
}
// 7. Deploy stack
var deployResult = await _docker.DeployStackAsync(dto.StackName, composeYaml);
// ----------------------------------------------------------------
// Step 7 — Deploy stack via Docker
// ----------------------------------------------------------------
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
// 8. Store instance in DB
// ----------------------------------------------------------------
// Step 8 — Persist instance metadata (no secret values stored)
// ----------------------------------------------------------------
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,
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = hostHttpPort,
ThemeHostPath = themeHostPath,
LibraryHostPath = _instanceDefaults.LibraryShareSubPath.Replace("{abbrev}", abbrev),
SmtpServer = _instanceDefaults.SmtpServer,
SmtpUsername = _instanceDefaults.SmtpUsername,
Constraints = JsonSerializer.Serialize(constraints),
TemplateRepoUrl = dto.TemplateRepoUrl,
TemplateRepoPat = dto.TemplateRepoPat,
TemplateRepoUrl = templateRepoUrl,
TemplateRepoPat = templateRepoPat,
TemplateLastFetch = template.FetchedAt,
Status = InstanceStatus.Active,
XiboUsername = dto.XiboUsername,
@@ -146,14 +221,14 @@ public class InstanceService
sw.Stop();
opLog.InstanceId = instance.Id;
opLog.Status = OperationStatus.Success;
opLog.Message = $"Instance deployed: {dto.StackName}";
opLog.Message = $"Instance deployed: {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);
stackName, instance.Id, sw.ElapsedMilliseconds);
deployResult.ServiceCount = 4;
deployResult.Message = "Instance deployed successfully.";
@@ -168,7 +243,7 @@ public class InstanceService
_db.OperationLogs.Add(opLog);
await _db.SaveChangesAsync();
_logger.LogError(ex, "Instance create failed: {StackName}", dto.StackName);
_logger.LogError(ex, "Instance create failed: abbrev={Abbrev}", abbrev);
throw;
}
}
@@ -208,20 +283,31 @@ public class InstanceService
var mysqlSecretName = AppConstants.CustomerMysqlSecretName(instance.CustomerName);
var abbrev = instance.CustomerAbbrev;
var templateEnvValues = ParseEnvLines(template.EnvLines, abbrev);
var smtpRewriteDomain = ExtractDomain(instance.SmtpUsername);
var renderCtx = new RenderContext
{
CustomerName = instance.CustomerName,
CustomerAbbrev = abbrev,
StackName = instance.StackName,
CmsServerName = instance.CmsServerName,
HostHttpPort = instance.HostHttpPort,
ThemeHostPath = instance.ThemeHostPath,
LibraryHostPath = instance.LibraryHostPath,
MySqlHost = _mysqlAdminOptions.Host,
MySqlPort = _mysqlAdminOptions.Port,
MySqlDatabase = _instanceDefaults.MySqlDatabaseTemplate.Replace("{abbrev}", abbrev),
MySqlUser = _instanceDefaults.MySqlUserTemplate.Replace("{abbrev}", abbrev),
SmtpServer = instance.SmtpServer,
SmtpUsername = instance.SmtpUsername,
SmtpPassword = _instanceDefaults.SmtpPassword,
SmtpRewriteDomain = smtpRewriteDomain,
TemplateYaml = template.Yaml,
TemplateEnvLines = template.EnvLines,
TemplateEnvValues = templateEnvValues,
Constraints = constraints,
SecretNames = new List<string> { AppConstants.GlobalSmtpSecretName, mysqlSecretName }
SecretNames = new List<string> { mysqlSecretName },
CifsCredentialsFilePath = "/etc/docker-cifs-credentials"
};
var composeYaml = _compose.Render(renderCtx);
@@ -430,4 +516,36 @@ public class InstanceService
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
return RandomNumberGenerator.GetString(chars, length);
}
/// <summary>
/// Parse template.env lines into a key/value dictionary.
/// Replaces {abbrev} placeholders with the customer abbreviation.
/// Skips empty lines and comments.
/// </summary>
private static Dictionary<string, string> ParseEnvLines(IEnumerable<string> lines, string abbrev)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines)
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
continue;
var eqIdx = trimmed.IndexOf('=');
if (eqIdx <= 0) continue;
var key = trimmed[..eqIdx].Trim();
var value = trimmed[(eqIdx + 1)..].Trim()
.Replace("{CUSTOMERABBREVIATION}", abbrev)
.Replace("{CUSTOMERABBREV}", abbrev)
.Replace("{abbrev}", abbrev);
result[key] = value;
}
return result;
}
/// <summary>Extracts the domain part from an email address, e.g. "user@ots-signs.com" → "ots-signs.com".</summary>
private static string ExtractDomain(string email)
{
var atIdx = email?.IndexOf('@') ?? -1;
return atIdx >= 0 ? email![(atIdx + 1)..] : string.Empty;
}
}

View File

@@ -1,178 +0,0 @@
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;
}
}

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