using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; 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; /// /// ViewModel for the Create Instance form. /// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials. /// All other config comes from the Settings page. /// public partial class CreateInstanceViewModel : ObservableObject { private readonly IServiceProvider _services; private readonly MainWindowViewModel _mainVm; [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; // NFS volume settings (per-instance, defaults loaded from global settings) [ObservableProperty] private string _nfsServer = string.Empty; [ObservableProperty] private string _nfsExport = string.Empty; [ObservableProperty] private string _nfsExportFolder = string.Empty; [ObservableProperty] private string _nfsExtraOptions = string.Empty; /// When enabled, existing Docker volumes for the stack are removed before deploying. [ObservableProperty] private bool _purgeStaleVolumes = false; // SSH host selection [ObservableProperty] private ObservableCollection _availableHosts = new(); [ObservableProperty] private SshHost? _selectedSshHost; // YML preview [ObservableProperty] private string _previewYml = string.Empty; [ObservableProperty] private bool _isLoadingYml; public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml); partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml)); // ── 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 PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—"; public string PreviewSecretHost => "global_mysql_host"; public string PreviewSecretPort => "global_mysql_port"; public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—"; public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—"; 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, MainWindowViewModel mainVm) { _services = services; _mainVm = mainVm; _ = LoadHostsAsync(); _ = LoadNfsDefaultsAsync(); } 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(PreviewSecretUser)); OnPropertyChanged(nameof(PreviewSecretHost)); OnPropertyChanged(nameof(PreviewSecretPort)); OnPropertyChanged(nameof(PreviewMySqlDb)); OnPropertyChanged(nameof(PreviewMySqlUser)); OnPropertyChanged(nameof(PreviewCmsUrl)); } private async Task LoadHostsAsync() { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); AvailableHosts = new ObservableCollection(hosts); } private async Task LoadNfsDefaultsAsync() { using var scope = _services.CreateScope(); var settings = scope.ServiceProvider.GetRequiredService(); NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty; NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty; NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty; NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty; } [RelayCommand] private async Task LoadYmlPreviewAsync() { if (!Valid) { PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview."; return; } IsLoadingYml = true; try { using var scope = _services.CreateScope(); var settings = scope.ServiceProvider.GetRequiredService(); var git = scope.ServiceProvider.GetRequiredService(); var composer = scope.ServiceProvider.GetRequiredService(); var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl); var repoPat = await settings.GetAsync(SettingsService.GitRepoPat); if (string.IsNullOrWhiteSpace(repoUrl)) { PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL."; return; } var templateConfig = await git.FetchAsync(repoUrl, repoPat); var abbrev = Abbrev; var stackName = $"{abbrev}-cms-stack"; 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_user")).Replace("{abbrev}", abbrev); var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.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 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"); // Use form values; fall back to saved global settings var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer; var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport; var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder; var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions; var ctx = new RenderContext { CustomerName = CustomerName.Trim(), 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, PangolinEndpoint = pangolinEndpoint, NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(), NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(), NfsServer = nfsServer, NfsExport = nfsExport, NfsExportFolder = nfsExportFolder, NfsExtraOptions = nfsOptions, }; PreviewYml = composer.Render(templateConfig.Yaml, ctx); } catch (Exception ex) { PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}"; } finally { IsLoadingYml = false; } } [RelayCommand] private async Task CopyYmlAsync() { if (string.IsNullOrEmpty(PreviewYml)) return; var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; if (mainWindow is null) return; var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard; if (clipboard is not null) await clipboard.SetTextAsync(PreviewYml); } [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 (singletons must know the target host before // InstanceService uses them internally for secrets and CLI operations) var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedSshHost); var dockerSecrets = _services.GetRequiredService(); dockerSecrets.SetHost(SelectedSshHost); using var scope = _services.CreateScope(); var instanceSvc = scope.ServiceProvider.GetRequiredService(); // InstanceService.CreateInstanceAsync handles the full provisioning flow: // 1. Clone template repo // 2. Generate MySQL password → create Docker Swarm secret // 3. Create MySQL database + SQL user (same password as the secret) // 4. Render compose YAML → deploy stack SetProgress(30, "Provisioning instance (MySQL user, secrets, 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(), NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(), NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(), NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(), NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(), PurgeStaleVolumes = PurgeStaleVolumes, }; var result = await instanceSvc.CreateInstanceAsync(dto); AppendOutput(result.Output ?? string.Empty); if (result.Success) { SetProgress(100, "Stack deployed successfully."); StatusMessage = $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms. " + "Open the details pane on the Instances page to complete setup."; _mainVm.NavigateToInstancesWithSelection(Abbrev); } else { SetProgress(0, "Deployment failed."); StatusMessage = $"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; } }