using System.Collections.ObjectModel; using Avalonia.Threading; 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.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. /// public partial class InstancesViewModel : ObservableObject { private readonly IServiceProvider _services; [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; // ── 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; private string? _pendingSelectAbbrev; public InstancesViewModel(IServiceProvider services) { _services = services; _ = 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; /// /// 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}"); } } 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; } [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; } } [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; } } }