using System.Collections.ObjectModel; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OTSSignsOrchestrator.Core.Data; using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Desktop.Models; using OTSSignsOrchestrator.Desktop.Services; namespace OTSSignsOrchestrator.Desktop.ViewModels; /// /// ViewModel for listing, viewing, and managing CMS instances. /// All data is fetched live from Docker Swarm hosts — nothing stored locally. /// Server operations (decommission, suspend, reactivate) go through the REST API. /// Real-time updates arrive via SignalR → WeakReferenceMessenger. /// public partial class InstancesViewModel : ObservableObject, IRecipient, IRecipient, IRecipient, IRecipient { private readonly IServiceProvider _services; private readonly ILogger _logger; private readonly IServerApiClient? _serverApi; [ObservableProperty] private ObservableCollection _instances = new(); [ObservableProperty] private LiveStackItem? _selectedInstance; [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private bool _isBusy; [ObservableProperty] private string _filterText = string.Empty; [ObservableProperty] private ObservableCollection _selectedServices = new(); // Available SSH hosts — loaded for display and used to scope operations [ObservableProperty] private ObservableCollection _availableHosts = new(); [ObservableProperty] private SshHost? _selectedSshHost; // ── P1 Authentik Banner ────────────────────────────────────────────────── [ObservableProperty] private bool _isAuthentikP1BannerVisible; [ObservableProperty] private string _authentikP1Message = string.Empty; // ── Container Logs ────────────────────────────────────────────────────── [ObservableProperty] private ObservableCollection _logEntries = new(); [ObservableProperty] private ObservableCollection _logServiceFilter = new(); [ObservableProperty] private string _selectedLogService = "All Services"; [ObservableProperty] private bool _isLogsPanelVisible; [ObservableProperty] private bool _isLogsAutoRefresh = true; [ObservableProperty] private bool _isLoadingLogs; [ObservableProperty] private string _logsStatusMessage = string.Empty; [ObservableProperty] private int _logTailLines = 200; private DispatcherTimer? _logRefreshTimer; private bool _isLogRefreshRunning; /// Raised when the instance details modal should be opened for the given ViewModel. public event Action? OpenDetailsRequested; /// /// Callback the View wires up to show a confirmation dialog. /// Parameters: (title, message) → returns true if the user confirmed. /// public Func>? ConfirmAsync { get; set; } /// /// Callback the View wires up to show a multi-step confirmation dialog for decommission. /// Parameters: (abbreviation) → returns true if confirmed through all steps. /// public Func>? ConfirmDecommissionAsync { get; set; } private string? _pendingSelectAbbrev; public InstancesViewModel(IServiceProvider services) { _services = services; _logger = services.GetRequiredService>(); _serverApi = services.GetService(); // Register for SignalR messages via WeakReferenceMessenger WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); _ = RefreshAllAsync(); } /// /// Queues an abbreviation to be auto-selected once the next live refresh completes. /// Call immediately after construction (before finishes). /// public void SetPendingSelection(string abbrev) => _pendingSelectAbbrev = abbrev; // ── SignalR Message Handlers ───────────────────────────────────────────── // These are called on the UI thread (SignalR handlers dispatch via Dispatcher.UIThread). void IRecipient.Receive(AlertRaisedMessage message) { var (severity, msg) = message.Value; if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) && msg.Contains("Authentik", StringComparison.OrdinalIgnoreCase)) { AuthentikP1Message = msg; IsAuthentikP1BannerVisible = true; } } void IRecipient.Receive(InstanceStatusChangedMessage message) { var (customerId, status) = message.Value; _logger.LogInformation("Instance status changed: customer={CustomerId} status={Status}", customerId, status); StatusMessage = $"Instance {customerId} status → {status}"; // Refresh the list to pick up the new status _ = RefreshAllAsync(); } void IRecipient.Receive(JobCreatedMessage message) { var (jobId, abbrev, jobType) = message.Value; _logger.LogInformation("Job created: {JobId} type={JobType} abbrev={Abbrev}", jobId, jobType, abbrev); StatusMessage = $"Job '{jobType}' created for {abbrev} (id: {jobId[..8]}…)"; } void IRecipient.Receive(JobCompletedMessage message) { var (jobId, success, summary) = message.Value; _logger.LogInformation("Job completed: {JobId} success={Success} summary={Summary}", jobId, success, summary); StatusMessage = success ? $"Job {jobId[..8]}… completed: {summary}" : $"Job {jobId[..8]}… failed: {summary}"; // Refresh the instance list to reflect changes from the completed job _ = RefreshAllAsync(); } // ── Load / Refresh ────────────────────────────────────────────────────── /// /// Enumerates all SSH hosts, then calls docker stack ls on each to build the /// live instance list. Only stacks matching *-cms-stack are shown. /// [RelayCommand] private async Task LoadInstancesAsync() => await RefreshAllAsync(); private async Task RefreshAllAsync() { IsBusy = true; StatusMessage = "Loading live instances from all hosts..."; SelectedServices = new ObservableCollection(); try { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync(); AvailableHosts = new ObservableCollection(hosts); var dockerCli = _services.GetRequiredService(); var all = new List(); var errors = new List(); foreach (var host in hosts) { try { dockerCli.SetHost(host); var stacks = await dockerCli.ListStacksAsync(); foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack"))) { all.Add(new LiveStackItem { StackName = stack.Name, CustomerAbbrev = stack.Name[..^10], ServiceCount = stack.ServiceCount, Host = host, }); } } catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); } } // Enrich with server-side customer IDs if the server API is available if (_serverApi is not null) { try { var fleet = await _serverApi.GetFleetAsync(); var lookup = fleet.ToDictionary(f => f.Abbreviation, f => f.CustomerId, StringComparer.OrdinalIgnoreCase); foreach (var item in all) { if (lookup.TryGetValue(item.CustomerAbbrev, out var customerId)) item.CustomerId = customerId; } } catch (Exception ex) { _logger.LogWarning(ex, "Could not enrich instances with server fleet data"); } } if (!string.IsNullOrWhiteSpace(FilterText)) all = all.Where(i => i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) || i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) || i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList(); Instances = new ObservableCollection(all); // Auto-select a pending instance (e.g. just deployed from Create Instance page) if (_pendingSelectAbbrev is not null) { SelectedInstance = all.FirstOrDefault(i => i.CustomerAbbrev.Equals(_pendingSelectAbbrev, StringComparison.OrdinalIgnoreCase)); _pendingSelectAbbrev = null; } var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s)."; if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}"; StatusMessage = msg; } catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private async Task InspectInstanceAsync() { if (SelectedInstance == null) return; IsBusy = true; StatusMessage = $"Inspecting '{SelectedInstance.StackName}'..."; try { var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedInstance.Host); var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); SelectedServices = new ObservableCollection(services); StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'."; // Populate service filter dropdown and show logs panel var filterItems = new List { "All Services" }; filterItems.AddRange(services.Select(s => s.Name)); LogServiceFilter = new ObservableCollection(filterItems); SelectedLogService = "All Services"; IsLogsPanelVisible = true; // Fetch initial logs and start auto-refresh await FetchLogsInternalAsync(); StartLogAutoRefresh(); } catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; } finally { IsBusy = false; } } // ── Container Log Commands ────────────────────────────────────────────── [RelayCommand] private async Task RefreshLogsAsync() { await FetchLogsInternalAsync(); } [RelayCommand] private void ToggleLogsAutoRefresh() { IsLogsAutoRefresh = !IsLogsAutoRefresh; if (IsLogsAutoRefresh) StartLogAutoRefresh(); else StopLogAutoRefresh(); } [RelayCommand] private void CloseLogsPanel() { StopLogAutoRefresh(); IsLogsPanelVisible = false; LogEntries = new ObservableCollection(); LogsStatusMessage = string.Empty; } partial void OnSelectedLogServiceChanged(string value) { // When user changes the service filter, refresh logs immediately if (IsLogsPanelVisible) _ = FetchLogsInternalAsync(); } private async Task FetchLogsInternalAsync() { if (SelectedInstance == null || _isLogRefreshRunning) return; _isLogRefreshRunning = true; IsLoadingLogs = true; try { var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedInstance.Host); string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService; var entries = await dockerCli.GetServiceLogsAsync( SelectedInstance.StackName, serviceFilter, LogTailLines); LogEntries = new ObservableCollection(entries); LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}"; } catch (Exception ex) { LogsStatusMessage = $"Error fetching logs: {ex.Message}"; } finally { IsLoadingLogs = false; _isLogRefreshRunning = false; } } private void StartLogAutoRefresh() { StopLogAutoRefresh(); if (!IsLogsAutoRefresh) return; _logRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) }; _logRefreshTimer.Tick += async (_, _) => { if (IsLogsPanelVisible && IsLogsAutoRefresh && !_isLogRefreshRunning) await FetchLogsInternalAsync(); }; _logRefreshTimer.Start(); } private void StopLogAutoRefresh() { _logRefreshTimer?.Stop(); _logRefreshTimer = null; } // ── Restart Commands ──────────────────────────────────────────────── [RelayCommand] private async Task RestartStackAsync() { if (SelectedInstance == null) return; if (ConfirmAsync is not null) { var confirmed = await ConfirmAsync( "Restart Stack", $"Are you sure you want to restart all services in '{SelectedInstance.StackName}'?\n\nThis will force-update every service in the stack, causing brief downtime."); if (!confirmed) return; } IsBusy = true; StatusMessage = $"Restarting all services in '{SelectedInstance.StackName}'..."; try { var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedInstance.Host); var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); var failures = new List(); for (var i = 0; i < services.Count; i++) { var svc = services[i]; StatusMessage = $"Restarting service {i + 1}/{services.Count}: {svc.Name}..."; var ok = await dockerCli.ForceUpdateServiceAsync(svc.Name); if (!ok) failures.Add(svc.Name); } StatusMessage = failures.Count == 0 ? $"All {services.Count} service(s) in '{SelectedInstance.StackName}' restarted successfully." : $"Restarted with errors — failed services: {string.Join(", ", failures)}"; } catch (Exception ex) { StatusMessage = $"Error restarting stack: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private async Task RestartServiceAsync(ServiceInfo? service) { if (service is null || SelectedInstance is null) return; if (ConfirmAsync is not null) { var confirmed = await ConfirmAsync( "Restart Service", $"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated."); if (!confirmed) return; } IsBusy = true; StatusMessage = $"Restarting service '{service.Name}'..."; try { var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedInstance.Host); var ok = await dockerCli.ForceUpdateServiceAsync(service.Name); StatusMessage = ok ? $"Service '{service.Name}' restarted successfully." : $"Failed to restart service '{service.Name}'."; // Refresh services to show updated replica status var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); SelectedServices = new ObservableCollection(services); } catch (Exception ex) { StatusMessage = $"Error restarting service: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private async Task DeleteInstanceAsync() { if (SelectedInstance == null) return; IsBusy = true; StatusMessage = $"Deleting {SelectedInstance.StackName}..."; try { var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedInstance.Host); var dockerSecrets = _services.GetRequiredService(); dockerSecrets.SetHost(SelectedInstance.Host); using var scope = _services.CreateScope(); var instanceSvc = scope.ServiceProvider.GetRequiredService(); var result = await instanceSvc.DeleteInstanceAsync( SelectedInstance.StackName, SelectedInstance.CustomerAbbrev); StatusMessage = result.Success ? $"Instance '{SelectedInstance.StackName}' deleted." : $"Delete failed: {result.ErrorMessage}"; await RefreshAllAsync(); } catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; } finally { IsBusy = false; } } [RelayCommand] private async Task RotateMySqlPasswordAsync() { if (SelectedInstance == null) return; IsBusy = true; StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}..."; try { var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedInstance.Host); var dockerSecrets = _services.GetRequiredService(); dockerSecrets.SetHost(SelectedInstance.Host); using var scope = _services.CreateScope(); var instanceSvc = scope.ServiceProvider.GetRequiredService(); var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(SelectedInstance.StackName); StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}"; await RefreshAllAsync(); } catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; } finally { IsBusy = false; } } // ── P1 Banner Commands ──────────────────────────────────────────────── [RelayCommand] private void DismissP1Banner() { IsAuthentikP1BannerVisible = false; AuthentikP1Message = string.Empty; } /// /// Called from a SignalR AlertRaised handler (runs on a background thread). /// CRITICAL: wraps all property updates with to /// avoid silent cross-thread exceptions in Avalonia. /// public void HandleAlertRaised(string severity, string message) { if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) && message.Contains("Authentik", StringComparison.OrdinalIgnoreCase)) { Dispatcher.UIThread.InvokeAsync(() => { AuthentikP1Message = message; IsAuthentikP1BannerVisible = true; }); } } // ── Server-side Job Commands (decommission, suspend, reactivate) ──────── // Desktop has NO direct infrastructure access — all operations go through the server REST API. [RelayCommand] private async Task DecommissionAsync(LiveStackItem? instance) { instance ??= SelectedInstance; if (instance is null) return; if (instance.CustomerId is null) { StatusMessage = "Cannot decommission: no server-side customer ID available for this instance."; return; } // Multi-step confirmation: user must type the abbreviation to confirm if (ConfirmDecommissionAsync is not null) { var confirmed = await ConfirmDecommissionAsync(instance.CustomerAbbrev); if (!confirmed) return; } else if (ConfirmAsync is not null) { var confirmed = await ConfirmAsync( "Decommission Instance", $"Are you sure you want to decommission '{instance.CustomerAbbrev}'?\n\n" + "This will:\n" + " • Remove all Docker services and stack\n" + " • Delete Docker secrets\n" + " • Remove NFS volumes and data\n" + " • Revoke Authentik provider\n" + " • Mark the customer as decommissioned\n\n" + "This action is IRREVERSIBLE."); if (!confirmed) return; } await CreateServerJobAsync(instance, "decommission"); } [RelayCommand] private async Task SuspendInstanceAsync(LiveStackItem? instance) { instance ??= SelectedInstance; if (instance is null) return; if (instance.CustomerId is null) { StatusMessage = "Cannot suspend: no server-side customer ID available for this instance."; return; } if (ConfirmAsync is not null) { var confirmed = await ConfirmAsync( "Suspend Instance", $"Are you sure you want to suspend '{instance.CustomerAbbrev}'?\n\n" + "The instance will be scaled to zero replicas. Data will be preserved."); if (!confirmed) return; } await CreateServerJobAsync(instance, "suspend"); } [RelayCommand] private async Task ReactivateInstanceAsync(LiveStackItem? instance) { instance ??= SelectedInstance; if (instance is null) return; if (instance.CustomerId is null) { StatusMessage = "Cannot reactivate: no server-side customer ID available for this instance."; return; } await CreateServerJobAsync(instance, "reactivate"); } private async Task CreateServerJobAsync(LiveStackItem instance, string jobType) { if (_serverApi is null) { StatusMessage = "Server API client is not configured."; return; } IsBusy = true; StatusMessage = $"Requesting '{jobType}' for {instance.CustomerAbbrev}..."; try { var response = await _serverApi.CreateJobAsync( new CreateJobRequest(instance.CustomerId!.Value, jobType, null)); StatusMessage = $"Job '{jobType}' created (id: {response.Id.ToString()[..8]}…). Status: {response.Status}"; _logger.LogInformation("Server job created: {JobId} type={JobType} customer={CustomerId}", response.Id, jobType, instance.CustomerId); } catch (Refit.ApiException ex) { StatusMessage = $"Server error creating '{jobType}' job: {ex.StatusCode} — {ex.Content}"; _logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId); } catch (Exception ex) { StatusMessage = $"Error creating '{jobType}' job: {ex.Message}"; _logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId); } finally { IsBusy = false; } } // ── Details ───────────────────────────────────────────────────────────── [RelayCommand] private async Task OpenDetailsAsync() { if (SelectedInstance == null) return; IsBusy = true; StatusMessage = $"Loading details for '{SelectedInstance.StackName}'..."; try { // Set the SSH host on singleton Docker services so modal operations target the right host var dockerCli = _services.GetRequiredService(); dockerCli.SetHost(SelectedInstance.Host); var dockerSecrets = _services.GetRequiredService(); dockerSecrets.SetHost(SelectedInstance.Host); var detailsVm = _services.GetRequiredService(); await detailsVm.LoadAsync(SelectedInstance); OpenDetailsRequested?.Invoke(detailsVm); StatusMessage = string.Empty; } catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; } finally { IsBusy = false; } } }