using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using OTSSignsOrchestrator.Core.Services; namespace OTSSignsOrchestrator.Desktop.ViewModels; /// /// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS, /// and Instance Defaults configuration, persisted via SettingsService. /// 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"; // ── NFS ───────────────────────────────────────────────────────────────── [ObservableProperty] private string _nfsServer = string.Empty; [ObservableProperty] private string _nfsExport = string.Empty; [ObservableProperty] private string _nfsExportFolder = string.Empty; [ObservableProperty] private string _nfsOptions = string.Empty; // ── 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_user"; [ObservableProperty] private string _defaultPhpPostMaxSize = "10G"; [ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G"; [ObservableProperty] private string _defaultPhpMaxExecutionTime = "600"; // ── Bitwarden Secrets Manager ───────────────────────────────── [ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com"; [ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com"; [ObservableProperty] private string _bitwardenAccessToken = string.Empty; [ObservableProperty] private string _bitwardenOrganizationId = string.Empty; [ObservableProperty] private string _bitwardenProjectId = string.Empty; // ── Xibo Bootstrap OAuth2 ───────────────────────────────────── [ObservableProperty] private string _xiboBootstrapClientId = string.Empty; [ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty; 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(); // 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"); // NFS NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty); NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty); NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty); NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty); // 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_user"); DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"); DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"); DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"); // Bitwarden BitwardenIdentityUrl = await svc.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com"); BitwardenApiUrl = await svc.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com"); BitwardenAccessToken = await svc.GetAsync(SettingsService.BitwardenAccessToken, string.Empty); BitwardenOrganizationId = await svc.GetAsync(SettingsService.BitwardenOrganizationId, string.Empty); BitwardenProjectId = await svc.GetAsync(SettingsService.BitwardenProjectId, string.Empty); // Xibo Bootstrap XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty); XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty); 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(); 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), // NFS (SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false), (SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false), (SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false), (SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, 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), // Bitwarden (SettingsService.BitwardenIdentityUrl, NullIfEmpty(BitwardenIdentityUrl), SettingsService.CatBitwarden, false), (SettingsService.BitwardenApiUrl, NullIfEmpty(BitwardenApiUrl), SettingsService.CatBitwarden, false), (SettingsService.BitwardenAccessToken, NullIfEmpty(BitwardenAccessToken), SettingsService.CatBitwarden, true), (SettingsService.BitwardenOrganizationId, NullIfEmpty(BitwardenOrganizationId), SettingsService.CatBitwarden, false), (SettingsService.BitwardenProjectId, NullIfEmpty(BitwardenProjectId), SettingsService.CatBitwarden, false), // Xibo Bootstrap (SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false), (SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true), }; await svc.SaveManyAsync(settings); StatusMessage = "Settings saved successfully."; } catch (Exception ex) { StatusMessage = $"Error saving settings: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private async Task TestBitwardenConnectionAsync() { if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId)) { StatusMessage = "Bitwarden Access Token and Organization ID are required."; return; } IsBusy = true; StatusMessage = "Testing Bitwarden Secrets Manager connection..."; try { using var scope = _services.CreateScope(); var bws = scope.ServiceProvider.GetRequiredService(); var secrets = await bws.ListSecretsAsync(); StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project."; } catch (Exception ex) { StatusMessage = $"Bitwarden connection failed: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private async Task TestXiboBootstrapAsync() { if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret)) { StatusMessage = "Xibo Bootstrap Client ID and Secret are required."; return; } IsBusy = true; StatusMessage = "Testing Xibo bootstrap credentials..."; try { using var scope = _services.CreateScope(); var xibo = scope.ServiceProvider.GetRequiredService(); var svc = scope.ServiceProvider.GetRequiredService(); var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com"); // Use a placeholder URL — user must configure a live instance for full test StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify."; } catch (Exception ex) { StatusMessage = $"Error: {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..."; try { if (!int.TryParse(MySqlPort, out var port)) port = 3306; var docker = _services.GetRequiredService(); var (connection, tunnel) = await docker.OpenMySqlConnectionAsync( MySqlHost, port, MySqlAdminUser, MySqlAdminPassword); await using var _ = connection; using var __ = tunnel; await using var cmd = connection.CreateCommand(); cmd.CommandText = "SELECT 1"; await cmd.ExecuteScalarAsync(); StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel)."; } catch (Exception ex) { StatusMessage = $"MySQL connection failed: {ex.Message}"; } finally { IsBusy = false; } } private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); }